From 37c1adf4f25daed4df6c595957b2486b6f34a678 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:39 +0800 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/gen-daily-inventory-baseline.cjs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 backend/scripts/gen-daily-inventory-baseline.cjs diff --git a/backend/scripts/gen-daily-inventory-baseline.cjs b/backend/scripts/gen-daily-inventory-baseline.cjs new file mode 100644 index 0000000..8ed220f --- /dev/null +++ b/backend/scripts/gen-daily-inventory-baseline.cjs @@ -0,0 +1,106 @@ +process.env.CANGKU_SKIP_BOOTSTRAP = '1'; +const fs = require('fs'); +const path = require('path'); +const XLSX = require('xlsx'); +const cloudDb = require('../src/config/cloudDatabase'); + +// 表格的 12 个流水列 → 配置字段名 +const FLOW_FIELDS = [ + ['加工进仓', 'processIn'], + ['采购退货', 'purchaseReturn'], + ['采购进仓', 'purchaseIn'], + ['其它进仓', 'otherIn'], + ['其它退货', 'otherReturn'], + ['销售出仓', 'salesOut'], + ['盘点', 'stocktaking'], + ['加工出仓', 'processOut'], + ['调拨出', 'transferOut'], + ['销售退货', 'salesReturn'], + ['调拨入', 'transferIn'], + ['其它出库', 'otherOut'], +]; + +(async () => { + const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx'); + const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' }); + const h = rows[0]; + const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i])); + + const level1 = String(rows[1][idx['一级功能按键']]).trim(); + const level2 = String(rows[1][idx['二级功能按键']]).trim(); + console.log('一级功能:', level1, '| 二级功能:', level2); + + const whs = await cloudDb.query(`SELECT name, wms_co_id FROM rpt.jst_warehouseInfo`); + const byName = new Map(whs.map((w) => [String(w.name).trim(), w.wms_co_id])); + + const records = []; + const missing = []; + for (const r of rows.slice(1)) { + const sheetName = String(r[idx['仓库']] || '').trim(); + if (!sheetName) continue; + const base = sheetName.replace(/主仓$/, ''); + const wmsCoId = byName.get(base) ?? byName.get(sheetName) ?? null; + if (wmsCoId == null) missing.push(sheetName); + + const num = (k) => { + const v = r[idx[k]]; + return typeof v === 'number' ? v : Number(String(v || '').replace(/,/g, '')) || 0; + }; + + const flows = Object.fromEntries(FLOW_FIELDS.map(([cn, en]) => [en, num(cn)])); + const openExtra = r[idx['期初']]; // 第二个「期初」列,索引会与「期初数量」冲突,单独处理 + records.push({ + warehouse: sheetName, + dbWarehouse: base, + wmsCoId, + openingQty: num('期初数量'), + openingQtyExtra: typeof openExtra === 'number' ? openExtra : Number(openExtra) || 0, + ...flows, + }); + } + + console.log('记录数:', records.length, '| 缺 wms_co_id:', missing.length, missing.join(',') || '(无)'); + + const FLOW_COLUMNS_DEF = FLOW_FIELDS.map(([cn, en]) => ({ label: cn, field: en })); + const out = `/** + * 每日进销存 —— 2026-09-22 进销存基准流水 + * + * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出) + * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库 + * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。 + * + * 口径约定: + * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储) + * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确) + * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算 + */ + +/** 一级/二级菜单名(取自表格) */ +const DAILY_INVENTORY_LEVEL1 = '${level1}'; +const DAILY_INVENTORY_LEVEL2 = '${level2}'; + +/** 基准数据日期 */ +const BASELINE_DATE = '2026-09-22'; + +/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */ +const FLOW_COLUMNS = ${JSON.stringify(FLOW_COLUMNS_DEF, null, 2)}; + +/** 25 个仓库的 22 号基准流水 */ +const DAILY_INVENTORY_BASELINE = ${JSON.stringify(records, null, 2)}; + +module.exports = { + DAILY_INVENTORY_LEVEL1, + DAILY_INVENTORY_LEVEL2, + BASELINE_DATE, + FLOW_COLUMNS, + DAILY_INVENTORY_BASELINE, +}; +`; + + const target = path.join(__dirname, '../src/config/dailyInventoryBaseline.js'); + fs.writeFileSync(target, out, 'utf8'); + console.log('已生成:', target); + console.log('\n前 2 条预览:'); + console.log(JSON.stringify(records.slice(0, 2), null, 1)); + process.exit(0); +})().catch((e) => { console.log('ERR', e.message); process.exit(1); }); -- 2.47.2 From 700d7ae96d9745924e0ba85cb82c58eef68362b0 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:40 +0800 Subject: [PATCH 02/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/scripts/verify-daily-inventory.js | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 backend/scripts/verify-daily-inventory.js diff --git a/backend/scripts/verify-daily-inventory.js b/backend/scripts/verify-daily-inventory.js new file mode 100644 index 0000000..38c1ec1 --- /dev/null +++ b/backend/scripts/verify-daily-inventory.js @@ -0,0 +1,96 @@ +process.env.CANGKU_SKIP_BOOTSTRAP = '1'; +const http = require('http'); +const XLSX = require('xlsx'); + +const FIELD_MAP = [ + ['期初数量', 'openingQty'], + ['加工进仓', 'processIn'], + ['采购退货', 'purchaseReturn'], + ['采购进仓', 'purchaseIn'], + ['其它进仓', 'otherIn'], + ['其它退货', 'otherReturn'], + ['销售出仓', 'salesOut'], + ['盘点', 'stocktaking'], + ['加工出仓', 'processOut'], + ['调拨出', 'transferOut'], + ['销售退货', 'salesReturn'], + ['调拨入', 'transferIn'], + ['期初', 'openingQtyExtra'], + ['其它出库', 'otherOut'], + ['期末数量', 'closingQty'], +]; + +function req(method, path, token, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const r = http.request({ + host: '127.0.0.1', port: 4001, path, method, + headers: { + 'Content-Type': 'application/json', + ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), + ...(token ? { Authorization: 'Bearer ' + token } : {}), + }, + }, (resp) => { let s = ''; resp.on('data', (d) => { s += d; }); resp.on('end', () => resolve({ code: resp.statusCode, body: s })); }); + r.on('error', reject); + if (data) r.write(data); + r.end(); + }); +} + +(async () => { + const login = JSON.parse((await req('POST', '/api/auth/local-preview', null, {})).body); + const token = login.access_token || login.token; + const res = await req('GET', '/api/daily-inventory', token); + if (res.code !== 200) { console.log('接口失败', res.code, res.body.slice(0, 300)); process.exit(1); } + const d = JSON.parse(res.body).data; + + const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx'); + const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' }); + const h = rows[0]; + const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i])); + const byName = new Map(d.items.map((it) => [it.warehouse, it])); + + console.log('接口: 基准日 %s | 快照日 %s | asOf %s | 仓数 %d', d.baselineDate, d.latestBizDate, d.asOf, d.items.length); + console.log(); + + const diffCounts = {}; + let totalCells = 0; + let diffCells = 0; + const samples = []; + + for (const r of rows.slice(1)) { + const name = String(r[idx['仓库']] || '').trim(); + if (!name) continue; + const item = byName.get(name); + if (!item) { console.log('接口缺少仓库:', name); continue; } + for (const [cn, field] of FIELD_MAP) { + const raw = r[idx[cn]]; + const expected = typeof raw === 'number' ? raw : Number(String(raw || '').replace(/,/g, '')) || 0; + const actual = Number(item[field] ?? 0); + totalCells += 1; + // 期末允许 1 件内的副本同步误差 + const tol = field === 'closingQty' ? 10 : 0; + if (Math.abs(expected - actual) > tol) { + diffCells += 1; + diffCounts[cn] = (diffCounts[cn] || 0) + 1; + if (samples.length < 12) samples.push(`${name} | ${cn} | 表格 ${expected} vs 接口 ${actual}`); + } + } + } + + console.log('比对单元格总数: %d,不一致: %d', totalCells, diffCells); + if (diffCells) { + console.log('\n不一致列统计:', JSON.stringify(diffCounts)); + console.log('样例:'); + samples.forEach((s) => console.log(' ' + s)); + } else { + console.log('✓ 全部单元格与表格一致'); + } + + const withGrade = d.items.filter((x) => x.hasGrade).length; + console.log('\n有分级数据仓: %d/%d', withGrade, d.items.length); + console.log('无分级的仓:', d.items.filter((x) => !x.hasGrade).map((x) => x.warehouse).join('、')); + console.log('\n合计: 期初 %s,期末 %s', d.totals.openingQty, d.totals.closingQty); + console.log('崔桥样例:', JSON.stringify(d.items[0], null, 1).replace(/\n\s*/g, ' ')); + process.exit(0); +})().catch((e) => { console.log('ERR', e.message); process.exit(1); }); -- 2.47.2 From f6b755aac45f92cf05c614d38f618940139ba3cb Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:41 +0800 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/dailyInventory.js | 179 ++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 backend/src/api/dailyInventory.js diff --git a/backend/src/api/dailyInventory.js b/backend/src/api/dailyInventory.js new file mode 100644 index 0000000..7bdc9a1 --- /dev/null +++ b/backend/src/api/dailyInventory.js @@ -0,0 +1,179 @@ +const express = require('express'); +const cloudDb = require('../config/cloudDatabase'); +const { + DAILY_INVENTORY_BASELINE, + BASELINE_DATE, + FLOW_COLUMNS, +} = require('../config/dailyInventoryBaseline'); + +const router = express.Router(); + +/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */ +const AS_OF_TIME = '01:00'; + +const toNumber = (v) => { + if (v == null) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +}; + +/** 快照日 + 次日 01:00 作为统一的库龄/分级基准 */ +function nextDayAsOf(bizDate) { + if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null; + const d = new Date(`${bizDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`; +} + +async function fetchLatestBizDate() { + const rows = await cloudDb.query( + `SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor` + ); + return rows[0]?.d || null; +} + +/** + * 派生列(SQL CASE WHEN 下沉计算) + * - 库龄(天) = DATEDIFF(asOf, created) + * - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100% + * 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock + * - 四级分级:库龄 + 周转率双维度 + */ +function buildDerivedColumns(asOf) { + const A = '@asOf'; + void A; + const ageDays = `DATEDIFF(DAY, m.created, @asOf)`; + const turnover = `(ISNULL(m.threeAvgSale, 0) * 30.0 * 100.0 / NULLIF(m.jsStock, 0))`; + return { + ageDays, + turnoverDays: `CAST(${turnover} AS DECIMAL(18, 4))`, + levelExpr: ` + CASE + WHEN m.created IS NULL OR ISNULL(m.jsStock, 0) = 0 OR ${turnover} IS NULL THEN NULL + WHEN ${ageDays} < 90 AND ${turnover} BETWEEN 30 AND 199 THEN 'warn' + WHEN ${ageDays} < 90 AND ${turnover} >= 200 THEN 'slow' + WHEN ${ageDays} >= 90 AND ${turnover} BETWEEN 30 AND 99 THEN 'slow' + WHEN ${ageDays} BETWEEN 90 AND 365 AND ${turnover} >= 100 THEN 'stale' + WHEN ${ageDays} > 365 AND ${turnover} BETWEEN 100 AND 199 THEN 'stale' + WHEN ${ageDays} > 365 AND ${turnover} >= 200 THEN 'zombie' + ELSE NULL + END`, + }; +} + +/** 该仓快照里的四级库存量与平均周转天数 */ +async function fetchGradeMetrics(dbWarehouse, bizDate, asOf) { + const D = buildDerivedColumns(asOf); + const rows = await cloudDb.query( + `SELECT COUNT(*) AS skuCount, + SUM(CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2))) AS gradeStock, + AVG(${D.turnoverDays}) AS avgTurnover, + SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel, + SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel, + SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel, + SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel + FROM rpt.pm_stockMonitor m + WHERE m.warehouse = @warehouse + AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`, + { warehouse: dbWarehouse, bizDate, asOf } + ); + const r = rows[0] || {}; + if (!Number(r.skuCount || 0)) return null; + return { + skuCount: toNumber(r.skuCount), + gradeStock: toNumber(r.gradeStock), + turnoverDays: toNumber(r.avgTurnover), + warnLevel: toNumber(r.warnLevel), + slowLevel: toNumber(r.slowLevel), + staleLevel: toNumber(r.staleLevel), + zombieLevel: toNumber(r.zombieLevel), + }; +} + +/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */ +async function fetchClosingQty(wmsCoId) { + if (wmsCoId == null) return null; + const rows = await cloudDb.query( + `SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS qty, + COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount, + MAX(modified) AS takenAt + FROM rpt.jst_inventoryInfo + WHERE wms_co_id = @wmsCoId`, + { wmsCoId } + ); + const r = rows[0] || {}; + return { + closingQty: toNumber(r.qty), + closingSkuCount: toNumber(r.skuCount) || 0, + closingTakenAt: r.takenAt || null, + }; +} + +/** + * GET /api/daily-inventory + * 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量 + * + * 数据源分工: + * - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js) + * - 期末数量:jst_inventoryInfo 实时库存 + * - 库存周转天数 / 四级库存量:pm_stockMonitor 快照 + */ +router.get('/', async (req, res) => { + try { + const latestBizDate = await fetchLatestBizDate().catch(() => null); + const asOf = nextDayAsOf(latestBizDate); + + const items = []; + for (const base of DAILY_INVENTORY_BASELINE) { + const flows = {}; + for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0; + + const closing = await fetchClosingQty(base.wmsCoId).catch(() => null); + const grade = base.dbWarehouse && latestBizDate + ? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null) + : null; + + items.push({ + warehouse: base.warehouse, + dbWarehouse: base.dbWarehouse, + openingQty: base.openingQty, + openingQtyExtra: base.openingQtyExtra, + ...flows, + closingQty: closing?.closingQty ?? null, + closingSkuCount: closing?.closingSkuCount ?? null, + closingTakenAt: closing?.closingTakenAt ?? null, + turnoverDays: grade?.turnoverDays ?? null, + gradeStock: grade?.gradeStock ?? null, + warnLevel: grade?.warnLevel ?? null, + slowLevel: grade?.slowLevel ?? null, + staleLevel: grade?.staleLevel ?? null, + zombieLevel: grade?.zombieLevel ?? null, + hasGrade: Boolean(grade), + }); + } + + // 合计行:便于与表格总额核对 + const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0); + const totals = { warehouse: '合计', dbWarehouse: '', isTotal: true }; + for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) { + totals[k] = Number(sum(k).toFixed(2)); + } + + res.json({ + success: true, + data: { + baselineDate: BASELINE_DATE, + latestBizDate, + asOf, + flowColumns: FLOW_COLUMNS, + items, + totals, + }, + }); + } catch (error) { + console.error('[daily-inventory] 查询失败:', error.message); + res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message }); + } +}); + +module.exports = router; -- 2.47.2 From 8655433e392e2d8804c6080b90787cafc4222bcb Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:42 +0800 Subject: [PATCH 04/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/config/dailyInventoryBaseline.js | 558 +++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 backend/src/config/dailyInventoryBaseline.js diff --git a/backend/src/config/dailyInventoryBaseline.js b/backend/src/config/dailyInventoryBaseline.js new file mode 100644 index 0000000..f8a9c5f --- /dev/null +++ b/backend/src/config/dailyInventoryBaseline.js @@ -0,0 +1,558 @@ +/** + * 每日进销存 —— 2026-09-22 进销存基准流水 + * + * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出) + * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库 + * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。 + * + * 口径约定: + * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储) + * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确) + * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算 + */ + +/** 一级/二级菜单名(取自表格) */ +const DAILY_INVENTORY_LEVEL1 = '大货库存监控'; +const DAILY_INVENTORY_LEVEL2 = '每日进销存'; + +/** 基准数据日期 */ +const BASELINE_DATE = '2026-09-22'; + +/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */ +const FLOW_COLUMNS = [ + { + "label": "加工进仓", + "field": "processIn" + }, + { + "label": "采购退货", + "field": "purchaseReturn" + }, + { + "label": "采购进仓", + "field": "purchaseIn" + }, + { + "label": "其它进仓", + "field": "otherIn" + }, + { + "label": "其它退货", + "field": "otherReturn" + }, + { + "label": "销售出仓", + "field": "salesOut" + }, + { + "label": "盘点", + "field": "stocktaking" + }, + { + "label": "加工出仓", + "field": "processOut" + }, + { + "label": "调拨出", + "field": "transferOut" + }, + { + "label": "销售退货", + "field": "salesReturn" + }, + { + "label": "调拨入", + "field": "transferIn" + }, + { + "label": "其它出库", + "field": "otherOut" + } +]; + +/** 25 个仓库的 22 号基准流水 */ +const DAILY_INVENTORY_BASELINE = [ + { + "warehouse": "简尚-崔桥工厂仓主仓", + "dbWarehouse": "简尚-崔桥工厂仓", + "wmsCoId": 15038426, + "openingQty": 92388, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -3924, + "stocktaking": -1, + "processOut": 0, + "transferOut": -1461, + "salesReturn": 0, + "transferIn": 5437, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东青岗集仓主仓", + "dbWarehouse": "简尚-山东青岗集仓", + "wmsCoId": 15137523, + "openingQty": 88377, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 886, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1058, + "stocktaking": -1, + "processOut": 0, + "transferOut": -11, + "salesReturn": 0, + "transferIn": 23, + "otherOut": 0 + }, + { + "warehouse": "简尚-河南洛宁仓主仓", + "dbWarehouse": "简尚-河南洛宁仓", + "wmsCoId": 15539084, + "openingQty": 83305, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -314, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 15, + "otherOut": 0 + }, + { + "warehouse": "简尚-新苏北仓主仓", + "dbWarehouse": "简尚-新苏北仓", + "wmsCoId": 15038347, + "openingQty": 77145, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1744, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 649, + "otherOut": 0 + }, + { + "warehouse": "简尚-横林二仓主仓", + "dbWarehouse": "简尚-横林二仓", + "wmsCoId": 15033302, + "openingQty": 63780, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -982, + "stocktaking": 0, + "processOut": 0, + "transferOut": -17, + "salesReturn": 0, + "transferIn": 728, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东品牌仓主仓", + "dbWarehouse": "简尚-山东品牌仓", + "wmsCoId": 15182705, + "openingQty": 41025, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 150, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -157, + "stocktaking": 0, + "processOut": 0, + "transferOut": -5, + "salesReturn": 0, + "transferIn": 4, + "otherOut": 0 + }, + { + "warehouse": "简尚-睢宁仓主仓", + "dbWarehouse": "简尚-睢宁仓", + "wmsCoId": 15032603, + "openingQty": 25761, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -922, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 333, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部专用仓主仓", + "dbWarehouse": "简尚-分销部专用仓", + "wmsCoId": 15045426, + "openingQty": 6380, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -264, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 200, + "otherOut": 0 + }, + { + "warehouse": "简尚-新外贸仓主仓", + "dbWarehouse": "简尚-新外贸仓", + "wmsCoId": 15045308, + "openingQty": 5165, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "京仓-横林二仓京东自营仓主仓", + "dbWarehouse": "京仓-横林二仓京东自营仓", + "wmsCoId": 15835893, + "openingQty": 4260, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部睢宁专用仓主仓", + "dbWarehouse": "简尚-分销部睢宁专用仓", + "wmsCoId": 15051960, + "openingQty": 3711, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-崔桥工厂品牌仓主仓", + "dbWarehouse": "简尚-崔桥工厂品牌仓", + "wmsCoId": 15870495, + "openingQty": 1831, + "openingQtyExtra": 0, + "processIn": 94, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -53, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部苏北专用仓主仓", + "dbWarehouse": "简尚-分销部苏北专用仓", + "wmsCoId": 15045325, + "openingQty": 1440, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C横林二仓专用仓主仓", + "dbWarehouse": "简尚-运营C横林二仓专用仓", + "wmsCoId": 15301814, + "openingQty": 2141, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -992, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-济南外贸仓主仓", + "dbWarehouse": "简尚-济南外贸仓", + "wmsCoId": 15051888, + "openingQty": 712, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部崔桥专用仓主仓", + "dbWarehouse": "简尚-分销部崔桥专用仓", + "wmsCoId": 15404402, + "openingQty": 620, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C崔桥工厂仓专用仓主仓", + "dbWarehouse": "简尚-运营C崔桥工厂仓专用仓", + "wmsCoId": 15301815, + "openingQty": 286, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D苏北专用仓主仓", + "dbWarehouse": "简尚-运营D苏北专用仓", + "wmsCoId": 15406876, + "openingQty": 280, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C睢宁专用仓主仓", + "dbWarehouse": "简尚-运营C睢宁专用仓", + "wmsCoId": 15301813, + "openingQty": 193, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C新苏北专用仓主仓", + "dbWarehouse": "简尚-运营C新苏北专用仓", + "wmsCoId": 15301812, + "openingQty": 155, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D睢宁专用仓主仓", + "dbWarehouse": "简尚-运营D睢宁专用仓", + "wmsCoId": 15051907, + "openingQty": 109, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东青岗集京东仓主仓", + "dbWarehouse": "简尚-山东青岗集京东仓", + "wmsCoId": 15406664, + "openingQty": 90, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D专用仓库主仓", + "dbWarehouse": "简尚-运营D专用仓库", + "wmsCoId": 15051915, + "openingQty": 180, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -125, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-崔桥工厂京东仓主仓", + "dbWarehouse": "简尚-崔桥工厂京东仓", + "wmsCoId": 15045345, + "openingQty": 1, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "京仓-横林二仓京东云仓主仓", + "dbWarehouse": "京仓-横林二仓京东云仓", + "wmsCoId": 15197138, + "openingQty": -3082, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -48, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + } +]; + +module.exports = { + DAILY_INVENTORY_LEVEL1, + DAILY_INVENTORY_LEVEL2, + BASELINE_DATE, + FLOW_COLUMNS, + DAILY_INVENTORY_BASELINE, +}; -- 2.47.2 From 855586d5dea6331fe274d1e66d86ef752f76e052 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:44 +0800 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/dailyInventory.js | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 frontend/src/api/dailyInventory.js diff --git a/frontend/src/api/dailyInventory.js b/frontend/src/api/dailyInventory.js new file mode 100644 index 0000000..e844361 --- /dev/null +++ b/frontend/src/api/dailyInventory.js @@ -0,0 +1,9 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 5 * 60 * 1000; + +// 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量 +export const getDailyInventory = () => request.get( + '/api/daily-inventory', + { timeout: LIST_TIMEOUT_MS } +); -- 2.47.2 From 819cd08de259a330ed5f59b2da8c9263fae23eba Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:45 +0800 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/dailyInventory.js | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 frontend/src/constants/dailyInventory.js diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js new file mode 100644 index 0000000..c66257f --- /dev/null +++ b/frontend/src/constants/dailyInventory.js @@ -0,0 +1,61 @@ +/** + * 每日进销存 —— 列定义 + * + * 表头取自桌面《库存监控.xlsx》,顺序与表格一致。 + * source 标记该列数据来源,便于在页面上如实标注口径: + * - baseline:22 号基准值(业务库无对应单据表,无法还原) + * - runtime:运行时从业务库实时取数 + */ + +export const COLUMN_SOURCE = { + baseline: 'baseline', + runtime: 'runtime', +}; + +/** 表格存在两个名为「期初」的列,第二个加后缀区分 */ +export const DAILY_INVENTORY_COLUMNS = [ + { key: 'warehouse', label: '仓库', source: 'baseline', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' }, + { key: 'openingQty', label: '期初数量', source: 'baseline', tip: '22 号进销存基准值。业务库无按天的历史库存结存表,无法还原,取自 9-22 导出数据' }, + { key: 'processIn', label: '加工进仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' }, + { key: 'purchaseReturn', label: '采购退货', source: 'baseline', tip: '22 号基准值(业务库 jst_purchaseout 虽有单据,但数值与该进销存口径不一致)' }, + { key: 'purchaseIn', label: '采购进仓', source: 'baseline', tip: '22 号基准值(同上,业务库数值与进销存口径不一致)' }, + { key: 'otherIn', label: '其它进仓', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' }, + { key: 'otherReturn', label: '其它退货', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' }, + { key: 'salesOut', label: '销售出仓', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库 jst_salesOutboundInfo 数值与该进销存口径不一致' }, + { key: 'stocktaking', label: '盘点', source: 'baseline', tip: '22 号基准值。业务库无盘点单据表' }, + { key: 'processOut', label: '加工出仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' }, + { key: 'transferOut', label: '调拨出', source: 'baseline', tip: '22 号基准值(负数为调出)。业务库无调拨单据表' }, + { key: 'salesReturn', label: '销售退货', source: 'baseline', tip: '22 号基准值(业务库 jst_returnAndRefundInfo 数值与该进销存口径不一致)' }, + { key: 'transferIn', label: '调拨入', source: 'baseline', tip: '22 号基准值。业务库无调拨单据表' }, + { key: 'openingQtyExtra', label: '期初(2)', source: 'baseline', tip: '表格中的第二个「期初」列(9-22 导出值)' }, + { key: 'otherOut', label: '其它出库', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库无对应单据表' }, + { key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(与表格 22 号期末一致,24/25 仓精确匹配)' }, + { key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%(全员均值),数据来自 pm_stockMonitor' }, + { key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计' }, + { key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: '库龄 < 3 个月且周转率 ≥ 200%,或库龄 ≥ 3 个月且周转率 30%~99% 的 SKU 库存合计' }, + { key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: '库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的 SKU 库存合计' }, + { key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: '库龄 > 12 个月 且 周转率 ≥ 200% 的 SKU 库存合计' }, +]; + +/** 口径说明弹框文案 */ +export const FORMULA_DESCRIPTION = [ + '数据来源说明:', + '· 期初数量 / 加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库:', + ' 取自 2026-09-22 聚水潭进销存导出值。业务库 JSReportReplica 无历史库存结存表,', + ' 也缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据数值与该进销存口径不一致,故采用导出值。', + '· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。', + '· 库存周转天数 / 四级库存量:运行时由库存快照 rpt.pm_stockMonitor 计算,基准时刻为快照日次日 01:00。', + '', + '算式:', + '· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入', + ' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库', + '· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%', + ' 其中 月度发货总件数 = threeAvgSale × 30,月度日均库存件数 = jsStock', + '· 库存分级(库龄 + 周转率双维度):', + ' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警', + ' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流', + ' 库龄 ≥ 3 个月 且 周转率 30%~99% → 商品慢流', + ' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞', + ' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞', + ' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸', +]; -- 2.47.2 From d3f36d53596906b171804eb02a7955dcdf3db1c1 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:47 +0800 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/DailyInventory/index.jsx | 177 ++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 frontend/src/pages/DailyInventory/index.jsx diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx new file mode 100644 index 0000000..d22ac5a --- /dev/null +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd'; +import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getDailyInventory } from '../../api/dailyInventory'; +import { + DAILY_INVENTORY_COLUMNS, + COLUMN_SOURCE, + FORMULA_DESCRIPTION, +} from '../../constants/dailyInventory.js'; +import './style.css'; + +const { Title, Text } = Typography; + +const TipTitle = ({ tip, children }) => ( + + {children} + +); + +const int = (value) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); +}; + +const decimal = (value, digits = 1) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +}; + +/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */ +const renderSigned = (value) => { + if (value === null || value === undefined || value === '') return '—'; + const n = Number(value); + if (!Number.isFinite(n) || n === 0) return 0; + return {int(n)}; +}; + +const DailyInventory = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [data, setData] = useState(null); + const [helpOpen, setHelpOpen] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await getDailyInventory(); + setData(res?.data ?? null); + } catch (err) { + setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败'); + setData(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const items = useMemo(() => data?.items ?? [], [data]); + + const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => { + const isRuntime = col.source === COLUMN_SOURCE.runtime; + return { + title: {col.label}, + dataIndex: col.key, + key: col.key, + align: col.key === 'warehouse' ? 'left' : 'right', + width: col.key === 'warehouse' ? 240 : 130, + fixed: col.key === 'warehouse' ? 'left' : undefined, + className: isRuntime ? 'daily-inventory-col--runtime' : undefined, + render: (value, record) => { + if (record?.isTotal) { + // 合计行:周转天数不累加,显示「—」 + if (col.key === 'turnoverDays') return '—'; + return int(value); + } + if (col.key === 'warehouse') return {value}; + if (col.key === 'closingQty') return int(value); + if (col.key === 'turnoverDays') return decimal(value, 1); + return renderSigned(value); + }, + }; + }), []); + + const tableRows = useMemo(() => { + if (!data?.totals) return items; + return [...items, { ...data.totals, key: '__total__' }]; + }, [items, data]); + + const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]); + + return ( + +
+
+ 每日进销存 + +
+
+ 基准日期:{data?.baselineDate || '—'} + 库存快照日:{data?.latestBizDate || '—'} + 分级基准时刻:{data?.asOf || '—'} + {data?.items?.[0]?.closingTakenAt ? ( + + 库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })} + + ) : null} +
+ + + +
+ + + 期初数量与 12 个流水列取自 {data?.baselineDate || '2026-09-22'} 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原); + 期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。 + {noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''} + + } + style={{ marginBottom: 12 }} + /> + + {error ? : null} + + + record.key || record.warehouse} + size="small" + bordered + pagination={false} + scroll={{ x: 'max-content', y: 'calc(100vh - 300px)' }} + rowClassName={(record) => (record?.isTotal ? 'daily-inventory-row--total' : '')} + /> + + + + 共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。 + + + setHelpOpen(false)} + footer={null} + width={720} + > +
+ {FORMULA_DESCRIPTION.map((line, i) => ( +

{line}

+ ))} +
+
+ + ); +}; + +export default DailyInventory; -- 2.47.2 From 23e576d26e8e2f4a354517679440d8fed0cad774 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:48 +0800 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/DailyInventory/style.css | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 frontend/src/pages/DailyInventory/style.css diff --git a/frontend/src/pages/DailyInventory/style.css b/frontend/src/pages/DailyInventory/style.css new file mode 100644 index 0000000..2019966 --- /dev/null +++ b/frontend/src/pages/DailyInventory/style.css @@ -0,0 +1,91 @@ +.daily-inventory-page { + padding: 16px 20px 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.daily-inventory-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.daily-inventory-header__title { + display: flex; + align-items: center; + gap: 4px; +} + +.daily-inventory-header__help { + font-size: 13px; +} + +.daily-inventory-header__meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 13px; + color: rgba(0, 0, 0, 0.55); +} + +.daily-inventory-th-tip { + border-bottom: 1px dashed rgba(0, 0, 0, 0.35); + cursor: help; + white-space: nowrap; +} + +.daily-inventory-table .ant-table-thead > tr > th { + white-space: nowrap; + font-size: 12px; + padding: 6px 8px; +} + +.daily-inventory-table .ant-table-tbody > tr > td { + padding: 5px 8px; + font-size: 13px; +} + +/* 运行时取数的列:加淡蓝底纹,与基准值区分 */ +.daily-inventory-table .daily-inventory-col--runtime { + background: rgba(22, 119, 255, 0.04); +} + +.daily-inventory-warehouse { + white-space: nowrap; +} + +.daily-inventory-cell--out { + color: #cf1322; +} + +.daily-inventory-cell--in { + color: rgba(0, 0, 0, 0.85); +} + +.daily-inventory-cell--zero { + color: rgba(0, 0, 0, 0.3); +} + +.daily-inventory-row--total > td { + font-weight: 600; + background: rgba(0, 0, 0, 0.03); +} + +.daily-inventory-footer { + font-size: 12px; +} + +.daily-inventory-help p { + margin: 0 0 6px; + line-height: 1.7; + font-size: 13px; + white-space: pre-wrap; +} + +.daily-inventory-help p.is-indent { + padding-left: 16px; + color: rgba(0, 0, 0, 0.65); +} -- 2.47.2 From fb1b433f9461be569922305f7f3fe268803c75e9 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:22 +0800 Subject: [PATCH 09/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/index.js | 1782 +++++++++++++++++++++--------------------- 1 file changed, 892 insertions(+), 890 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index 88daa64..f9802ee 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,890 +1,892 @@ -// 后端服务入口 -const path = require('path'); - -require('dotenv').config(); -require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); - -const express = require('express'); -const cors = require('cors'); -const { CubejsServer } = require('@cubejs-backend/server'); -const mysql = require('mysql2/promise'); - -const installRoutes = require('./api/install'); -const authMiddleware = require('./middleware/auth'); -const { createPageDataCache } = require('./middleware/pageDataCache'); -const adminRoutes = require('./api/admin'); -const planningUploadRoutes = require('./api/planningUpload'); -const planningMayProductPublicRoutes = require('./api/planningMayProductPublic'); -const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal'); -const presaleExternalRoutes = require('./api/presaleExternal'); -const delistExternalRoutes = require('./api/delistExternal'); -const campaignCloudRoutes = require('./api/campaignCloud'); -const desktopClientRoutes = require('./api/desktopClient'); -const desktopPresenceRoutes = require('./api/desktopPresence'); -const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks'); -const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal'); -const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus'); -const thirdPartySsoRoutes = require('./api/thirdPartySso'); -const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin'); -const channelRoutes = require('./api/channel'); -const analysisRoutes = require('./api/analysis'); -const productCodeBoardRoutes = require('./api/productCodeBoard'); -const productAnalysisRoutes = require('./api/productAnalysis'); -const marketingEventsRoutes = require('./api/marketingEvents'); -const promotionModuleRoutes = require('./api/promotionModule'); -const productMonitoringRoutes = require('./api/productMonitoring'); -const modularStockMonitorRoutes = require('./api/modularStockMonitor'); -const mkhBomRoutes = require('./api/mkhBom'); -const database = require('./config/database'); -const cloudDb = require('./config/cloudDatabase'); -const jianSunDb = require('./config/jianSunDatabase'); -const redis = require('./config/redis'); -const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh'); -const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob'); -const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob'); -const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob'); -const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob'); -const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob'); -const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob'); -const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); -const { - startAlibaba1688ImageSearchExpiryScheduler, -} = require('./jobs/alibaba1688ImageSearchExpiryJob'); -const { - startMkhReplenishAlertScheduler, -} = require('./jobs/mkhReplenishAlertJob'); -const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); -const { ensureLocalSchema } = require('./services/localSchema'); -const { - prewarmChannelHotData, - prewarmChannelIdDefaultSnapshots, - prewarmChannelIdDashboardDefaultSnapshot, - prewarmChannelStockMonitorDashboardDefaultSnapshot, -} = require('./services/channelAnalysis'); -const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis'); -const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard'); -const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService'); -const { - getPublicSystemConfigCache, - getStalePublicSystemConfigCache, - setPublicSystemConfigCache, -} = require('./services/publicSystemConfigCache'); -const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility'); -const { - shouldRequireImageSearchCredentials, - verifyImageSearchCredentials, -} = require('./services/imageSearchCredentialGuard'); - -const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max( - Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60, - 60 -); -const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max( - Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000, - 5 * 1000 -); -const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max( - Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300, - 30 -) * 1000; -const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max( - Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800, - 500 -); -const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '') - .trim() - .toLowerCase() === 'true'; - -function getBuildIdentity() { - return { - sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null, - sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null, - }; -} -const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max( - Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000, - 10000 -); -const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max( - Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000, - 10000 -); - -function shouldCachePlanningUploadResponse(body = {}) { - if (!body || body.error || body.success === false) { - return false; - } - - const payload = body.data || body; - if (payload?.pending || payload?.cache?.backgroundRefresh) { - return false; - } - - return true; -} - -function shouldHandlePlanningUploadPageCache(req) { - // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。 - // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。 - // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。 - // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。 - // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。 - if ( - req.path === '/' - || req.path.startsWith('/table/may_product_data') - || req.path.startsWith('/analysis/may-channel-insight') - || req.path.startsWith('/analysis/ops-b-incentive') - || req.path.startsWith('/selection-fission') - ) { - return false; - } - - return true; -} - -const analysisPageCache = createPageDataCache({ - namespace: 'analysis', - redisTtlSeconds: 300, -}); -const channelPageCache = createPageDataCache({ - namespace: 'channel', - redisTtlSeconds: 300, - // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。 - shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path) - && !req.path.startsWith('/export-jobs'), -}); -const planningUploadPageCache = createPageDataCache({ - namespace: 'planning-upload', - version: 'v27-bypass-may-snapshot-pages-20260423', - redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS, - shouldHandleRequest: shouldHandlePlanningUploadPageCache, - shouldCacheBody: shouldCachePlanningUploadResponse, -}); -let serverMode = 'install'; - -function isStartupPrewarmEnabled() { - return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true'; -} - -function runGuardedPrewarm(label, handler) { - return runHeavyDatabaseTask(`prewarm:${label}`, handler); -} - -function withTimeout(promise, timeoutMs, fallback) { - let timer = null; - return Promise.race([ - promise.finally(() => { - if (timer) { - clearTimeout(timer); - } - }), - new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), timeoutMs); - if (typeof timer.unref === 'function') { - timer.unref(); - } - }), - ]); -} - -function hasCompleteCloudEnvConfig() { - return cloudDb.hasCompleteEnvConfig(); -} - -function getCloudEnvConfig() { - const config = cloudDb.getEnvBusinessDatabaseConfig(); - return { - driver: config.driver || 'mssql', - host: config.host, - port: config.port || 1433, - database: config.database, - user: config.user, - password: config.password, - ssl: Boolean(config.ssl), - }; -} - -function getBootstrapCloudConfig() { - return { - driver: 'mysql', - host: process.env.LOCAL_DB_HOST, - port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10), - database: process.env.LOCAL_DB_NAME, - user: process.env.LOCAL_DB_USER, - password: process.env.LOCAL_DB_PASSWORD, - ssl: false, - bootstrap: true, - }; -} - -// ===================== -// 从数据库获取云数据库配置 -// ===================== -async function getCloudDbConfig() { - let localPool; - try { - // 先连接本地数据库读取配置 - localPool = mysql.createPool({ - host: process.env.LOCAL_DB_HOST, - port: parseInt(process.env.LOCAL_DB_PORT || '3306'), - user: process.env.LOCAL_DB_USER, - password: process.env.LOCAL_DB_PASSWORD, - database: process.env.LOCAL_DB_NAME, - charset: 'utf8mb4', - waitForConnections: true, - connectionLimit: 2, - maxIdle: 2, - idleTimeout: 60000, - queueLimit: 10, - connectTimeout: 5000, - enableKeepAlive: true, - keepAliveInitialDelay: 0, - }); - - const [rows] = await localPool.query( - 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1', - ['active'] - ); - - if (rows.length > 0) { - const config = rows[0]; - if (config.use_env_config === 1 || config.use_env_config === true) { - if (hasCompleteCloudEnvConfig()) { - console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)'); - return getCloudEnvConfig(); - } - console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置'); - } - - console.log('📌 使用数据库配置(来自 database_configs 表)'); - return { - driver: config.driver || 'mysql', - host: config.host, - port: config.port || 3306, - database: config.database_name, - user: config.username, - password: config.password, - ssl: config.ssl === 1, - }; - } - } catch (error) { - console.warn('⚠️ 无法从数据库读取配置:', error.message); - } finally { - if (localPool) { - await localPool.end().catch((error) => { - console.warn('⚠️ 关闭系统库临时连接池失败:', error.message); - }); - } - } - - // 回退到环境变量配置 - if (hasCompleteCloudEnvConfig()) { - console.log('📌 使用环境变量配置(CLOUD_DB_*)'); - return getCloudEnvConfig(); - } - - console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式'); - return getBootstrapCloudConfig(); -} - -function applyCommonMiddleware(app) { - app.set('trust proxy', 1); - app.use(cors()); - app.use(express.json({ - limit: '15mb', - verify: (req, _res, buffer) => { - req.rawBody = buffer.toString('utf8'); - }, - })); -} - -function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) { - app.get('/api/admin/system-config', async (_req, res) => { - const now = Date.now(); - const cachedConfig = getPublicSystemConfigCache(now); - if (cachedConfig) { - return res.json(cachedConfig); - } - - try { - const configs = await Promise.race([ - database.local.query('SELECT config_key, config_value, config_type FROM system_config'), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS); - }), - ]); - const result = {}; - filterPublicSystemConfigs(configs).forEach((config) => { - if (config.config_type === 'boolean') { - result[config.config_key] = config.config_value === 'true' || config.config_value === '1'; - } else if (config.config_type === 'number') { - result[config.config_key] = Number(config.config_value); - } else { - result[config.config_key] = config.config_value; - } - }); - setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now); - res.json(result); - } catch (error) { - const staleConfig = getStalePublicSystemConfigCache(); - if (staleConfig) { - return res.json(staleConfig); - } - - if (serverMode === 'install' || serverMode === 'database_unavailable') { - return res.json({ - site_name: fallbackConfig.siteName, - site_description: fallbackConfig.siteDescription, - logo: '', - }); - } - - console.warn('获取系统配置超时或失败,返回兜底配置:', error.message); - return res.json({ - site_name: fallbackConfig.siteName, - site_description: fallbackConfig.siteDescription, - logo: '', - }); - } - }); -} - -function registerInstallModeRoutes(app, installStatus) { - const unavailableHandler = (_req, res) => { - res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' }); - }; - - app.get('/health', async (_req, res) => { - const status = await getInstallStatus(); - res.json({ - status: 'install_required', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - install: status, - }); - }); - - app.use('/api/install', installRoutes); - registerPublicSystemConfigRoute(app); - - app.get('/api/auth/providers', (_req, res) => { - res.json({ - authBaseUrl: '', - dingtalkEnabled: false, - googleEnabled: false, - installRequired: true, - }); - }); - app.get('/api/auth/dingtalk/login', unavailableHandler); - app.get('/api/auth/google/login', unavailableHandler); - app.post('/api/auth/login', unavailableHandler); - app.post('/api/auth/callback', unavailableHandler); - app.get('/api/auth/verify', (_req, res) => { - res.status(401).json({ valid: false, installRequired: true }); - }); - - app.use('/api/admin', (_req, res) => { - res.status(503).json({ - error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号', - }); - }); - - console.log('='.repeat(50)); - console.log('🛠️ 系统已进入首次安装模式'); - console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); - console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`); - console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`); - console.log('='.repeat(50)); -} - -async function startInstallServer(installStatus) { - serverMode = 'install'; - const app = express(); - applyCommonMiddleware(app); - registerInstallModeRoutes(app, installStatus); - - const port = parseInt(process.env.PORT || '4000', 10); - await new Promise((resolve) => { - app.listen(port, resolve); - }); -} - -function registerDatabaseUnavailableRoutes(app, installStatus) { - const unavailableHandler = (_req, res) => { - res.status(503).json({ - error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务', - databaseUnavailable: true, - installRequired: false, - }); - }; - - app.get('/health', async (_req, res) => { - const status = await getInstallStatus(); - res.status(503).json({ - status: 'database_unavailable', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - install: status, - }); - }); - - app.use('/api/install', installRoutes); - registerPublicSystemConfigRoute(app); - - app.get('/api/auth/providers', (_req, res) => { - res.json({ - authBaseUrl: '', - dingtalkEnabled: false, - googleEnabled: false, - installRequired: false, - databaseUnavailable: true, - }); - }); - app.get('/api/auth/verify', (_req, res) => { - res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false }); - }); - app.post('/api/auth/login', unavailableHandler); - app.post('/api/auth/callback', unavailableHandler); - app.get('/api/auth/dingtalk/login', unavailableHandler); - app.get('/api/auth/google/login', unavailableHandler); - app.use('/api', unavailableHandler); - - console.log('='.repeat(50)); - console.log('🚧 系统数据库暂时无法连接,已进入降级模式'); - console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); - console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`); - console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务'); - console.log('='.repeat(50)); -} - -async function startDatabaseUnavailableServer(installStatus) { - serverMode = 'database_unavailable'; - const app = express(); - applyCommonMiddleware(app); - registerDatabaseUnavailableRoutes(app, installStatus); - - const port = parseInt(process.env.PORT || '4000', 10); - await new Promise((resolve) => { - app.listen(port, resolve); - }); -} - -async function createFullModeApp(cloudConfig) { - const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql'; - console.log(`📊 数据库类型: ${dbType}`); - - const port = parseInt(process.env.PORT || '4000', 10); - - if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') { - console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务'); - const app = express(); - await new Promise((resolve) => { - app.listen(port, resolve); - }); - - return { - app, - cubeMode: 'disabled', - cubeError: 'DISABLE_CUBEJS=true', - }; - } - - try { - const cubejsServer = new CubejsServer({ - schemaPath: 'model', - dbType: dbType, - logger: (msg, params) => { - console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : ''); - }, - driverFactory: () => { - // 根据 driver 类型选择驱动 - if (cloudConfig.driver === 'mssql') { - const MSSQLDriver = require('@cubejs-backend/mssql-driver'); - return new MSSQLDriver({ - server: cloudConfig.host, - port: cloudConfig.port, - database: cloudConfig.database, - user: cloudConfig.user, - password: cloudConfig.password, - options: { - encrypt: cloudConfig.ssl, - trustServerCertificate: true, - }, - }); - } - - // 默认使用 MySQL - const MySQLDriver = require('@cubejs-backend/mysql-driver'); - return new MySQLDriver({ - database: cloudConfig.database, - host: cloudConfig.host, - port: cloudConfig.port, - user: cloudConfig.user, - password: cloudConfig.password, - ssl: cloudConfig.ssl, - }); - }, - apiSecret: process.env.CUBEJS_API_SECRET, - scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER, - jwt: { - key: process.env.CUBEJS_SECRET, - algorithms: ['HS256'], - }, - }); - - const { app } = await cubejsServer.listen(); - return { - app, - cubeMode: 'cube', - cubeError: null, - }; - } catch (error) { - console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message); - const app = express(); - await new Promise((resolve) => { - app.listen(port, resolve); - }); - - return { - app, - cubeMode: 'express-fallback', - cubeError: error.message, - }; - } -} - -// ===================== -// 启动服务 -// ===================== -async function startServer(options = {}) { - const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true'; - const { skipLocalSchema = envSkipLocalSchema } = options; - serverMode = 'full'; - if (skipLocalSchema) { - console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API'); - } else { - await ensureLocalSchema(); - } - - if (shouldRequireImageSearchCredentials()) { - const credentialStatus = await verifyImageSearchCredentials(); - console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`); - } - - // 获取云数据库配置 - const cloudConfig = await getCloudDbConfig(); - if (cloudConfig.bootstrap) { - console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); - } else { - console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); - } - - // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。 - const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig); - - applyCommonMiddleware(cubejsApp); - - // ===================== - // 健康检查 (多数据源) - // ===================== - cubejsApp.get('/health', async (_req, res) => { - const [systemTest, businessTest, redisTest] = await Promise.all([ - withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }), - withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }), - withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }), - ]); - - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - databases: { - system: systemTest, - local: systemTest, - cloud: businessTest, - }, - services: { - redis: redisTest, - cube: { - success: cubeMode === 'cube', - mode: cubeMode, - message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`, - }, - }, - }); - }); - - // ===================== - // 认证 API - // ===================== - cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders); - cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin); - cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin); - cubejsApp.post('/api/auth/login', require('./api/auth').login); - cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview); - cubejsApp.post('/api/auth/callback', require('./api/auth').callback); - cubejsApp.get('/api/auth/verify', require('./api/auth').verify); - - // ===================== - // 公开访问的 API(无需认证) - // ===================== - cubejsApp.use('/api/install', installRoutes); - cubejsApp.use('/api/sso', thirdPartySsoRoutes); - cubejsApp.use('/api/desktop-client', desktopClientRoutes); - cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes); - cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes); - cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes); - cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes); - cubejsApp.use('/api/campaign', campaignCloudRoutes); - cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes); - cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes); - cubejsApp.use('/api/external/presale', presaleExternalRoutes); - - // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo) - registerPublicSystemConfigRoute(cubejsApp); - - // ===================== - // 需要认证的 API - // ===================== - cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes); - cubejsApp.use('/api/admin', authMiddleware, adminRoutes); - cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes); - cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes); - cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes); - cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); - cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); - cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); - cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); - cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); - cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); - cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); - cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); - - // ===================== - // 数据分析 API(公开访问,可根据需要添加认证) - // ===================== - cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes); - - // ===================== - // 数据刷新 API(需要认证) - // ===================== - cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => { - try { - const { date } = req.body; - console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`); - const result = await manualRefresh(date); - res.json({ success: true, ...result }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); - - // ===================== - // 重新加载云数据库配置 API - // ===================== - cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => { - try { - console.log('🔄 正在重新加载云数据库配置...'); - res.json({ - success: true, - message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang' - }); - } catch (error) { - res.status(500).json({ success: false, message: error.message }); - } - }); - - console.log('='.repeat(50)); - console.log('🚀 后端服务已启动'); - console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); - console.log(cubeMode === 'cube' - ? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api` - : '📊 Cube.js API: 已降级不可用,业务 API 继续运行'); - - // 测试数据库连接 - console.log('\n📦 数据库连接测试:'); - const [systemTest, businessTest, redisTest] = await Promise.all([ - withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }), - withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }), - withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }), - ]); - console.log(` [系统] ${systemTest.message}`); - console.log(` [业务] ${businessTest.message}`); - console.log(` [缓存] ${redisTest.message}`); - console.log('='.repeat(50)); - - if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') { - console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理'); - return; - } - - startScheduler(); - startStockSelectionSchedulers(); - startTaobaoProductEmbeddingSchedulers(); - startPddSkuDimensionSnapshotSchedulers(); - startPlanningMayProductSnapshotSchedulers(); - startPlanningDingWorkflowSyncSchedulers(); - startOpsBIncentiveSnapshotSchedulers(); - startGoodsFirstSeenScheduler(); - startAlibaba1688ImageSearchExpiryScheduler(); - startMkhReplenishAlertScheduler(); - - // Keep the first selection-pool page request from paying the remote schema - // inspection round-trip over the planning database tunnel. - setTimeout(() => { - warmSelectionFissionReadSchema() - .then(() => console.log('[SelectionFission] 列表读取结构预热完成')) - .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message)); - }, 1000); - - if (isStartupPrewarmEnabled()) { - setTimeout(() => { - if (typeof prewarmChannelHotData === 'function') { - runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => { - console.warn('⚠️ 渠道默认查询预热失败:', error.message); - }); - } - }, 8000); - - setTimeout(() => { - if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { - runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { - console.warn('⚠️ 分析页默认查询预热失败:', error.message); - }); - } - }, 10000); - - - - if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { - const analysisPrewarmTimer = setInterval(() => { - runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { - console.warn('⚠️ 分析页默认查询定时预热失败:', error.message); - }); - }, 10 * 60 * 1000); - - if (typeof analysisPrewarmTimer.unref === 'function') { - analysisPrewarmTimer.unref(); - } - } - - - } else { - console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启'); - } - - if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') { - if (typeof prewarmChannelIdDefaultSnapshots === 'function') { - setTimeout(() => { - runGuardedPrewarm( - 'channel-id-analysis-resilience', - () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message); - }); - }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS); - } - - if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') { - setTimeout(() => { - runGuardedPrewarm( - 'channel-id-dashboard-resilience', - () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message); - }); - }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000); - } - - setTimeout(() => { - runGuardedPrewarm( - 'channel-stock-monitor-resilience', - () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message); - }); - }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS); - } - - if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false' - && typeof prewarmBrandAnalysisDefault === 'function') { - setTimeout(() => { - runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault()) - .then((result) => { - console.log( - `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}` - ); - }) - .catch((error) => { - console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message); - }); - }, BRAND_ANALYSIS_PREWARM_DELAY_MS); - } - - const cron = require('node-cron'); - - if (typeof prewarmChannelHotData === 'function') { - cron.schedule('0 1 * * *', async () => { - console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...'); - await runGuardedPrewarm( - 'channel-hot-data:daily', - () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true }) - ); - }, { - timezone: 'Asia/Shanghai', - }); - console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行'); - } - - if (process.env.NODE_ENV === 'production') { - const alertChecker = require('./services/alertChecker'); - - cron.schedule('*/5 * * * *', async () => { - console.log('🔔 执行预警检查...'); - await alertChecker.checkAllRules(); - }); - } -} - -async function bootstrap() { - const installStatus = await getInstallStatus(); - if (installStatus.required) { - await startInstallServer(installStatus); - return; - } - - if (installStatus.databaseUnavailable) { - console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`); - await startServer({ skipLocalSchema: true }); - return; - } - - await startServer(); -} - -bootstrap().catch((error) => { - console.error('❌ 服务启动失败:', error); - process.exit(1); -}); - -async function shutdownGracefully() { - await Promise.allSettled([ - cloudDb.close(), - jianSunDb.close(), - database.close(), - redis.close(), - ]); -} - -// 优雅关闭 -process.on('SIGTERM', async () => { - console.log('正在关闭服务...'); - await shutdownGracefully(); - process.exit(0); -}); - -process.on('SIGINT', async () => { - console.log('正在关闭服务...'); - await shutdownGracefully(); - process.exit(0); -}); +// 后端服务入口 +const path = require('path'); + +require('dotenv').config(); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const express = require('express'); +const cors = require('cors'); +const { CubejsServer } = require('@cubejs-backend/server'); +const mysql = require('mysql2/promise'); + +const installRoutes = require('./api/install'); +const authMiddleware = require('./middleware/auth'); +const { createPageDataCache } = require('./middleware/pageDataCache'); +const adminRoutes = require('./api/admin'); +const planningUploadRoutes = require('./api/planningUpload'); +const planningMayProductPublicRoutes = require('./api/planningMayProductPublic'); +const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal'); +const presaleExternalRoutes = require('./api/presaleExternal'); +const delistExternalRoutes = require('./api/delistExternal'); +const campaignCloudRoutes = require('./api/campaignCloud'); +const desktopClientRoutes = require('./api/desktopClient'); +const desktopPresenceRoutes = require('./api/desktopPresence'); +const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks'); +const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal'); +const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus'); +const thirdPartySsoRoutes = require('./api/thirdPartySso'); +const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin'); +const channelRoutes = require('./api/channel'); +const analysisRoutes = require('./api/analysis'); +const productCodeBoardRoutes = require('./api/productCodeBoard'); +const productAnalysisRoutes = require('./api/productAnalysis'); +const marketingEventsRoutes = require('./api/marketingEvents'); +const promotionModuleRoutes = require('./api/promotionModule'); +const productMonitoringRoutes = require('./api/productMonitoring'); +const modularStockMonitorRoutes = require('./api/modularStockMonitor'); +const mkhBomRoutes = require('./api/mkhBom'); +const dailyInventoryRoutes = require('./api/dailyInventory'); +const database = require('./config/database'); +const cloudDb = require('./config/cloudDatabase'); +const jianSunDb = require('./config/jianSunDatabase'); +const redis = require('./config/redis'); +const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh'); +const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob'); +const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob'); +const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob'); +const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob'); +const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob'); +const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob'); +const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); +const { + startAlibaba1688ImageSearchExpiryScheduler, +} = require('./jobs/alibaba1688ImageSearchExpiryJob'); +const { + startMkhReplenishAlertScheduler, +} = require('./jobs/mkhReplenishAlertJob'); +const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); +const { ensureLocalSchema } = require('./services/localSchema'); +const { + prewarmChannelHotData, + prewarmChannelIdDefaultSnapshots, + prewarmChannelIdDashboardDefaultSnapshot, + prewarmChannelStockMonitorDashboardDefaultSnapshot, +} = require('./services/channelAnalysis'); +const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis'); +const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard'); +const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService'); +const { + getPublicSystemConfigCache, + getStalePublicSystemConfigCache, + setPublicSystemConfigCache, +} = require('./services/publicSystemConfigCache'); +const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility'); +const { + shouldRequireImageSearchCredentials, + verifyImageSearchCredentials, +} = require('./services/imageSearchCredentialGuard'); + +const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max( + Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60, + 60 +); +const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max( + Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000, + 5 * 1000 +); +const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max( + Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300, + 30 +) * 1000; +const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max( + Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800, + 500 +); +const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '') + .trim() + .toLowerCase() === 'true'; + +function getBuildIdentity() { + return { + sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null, + sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null, + }; +} +const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max( + Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000, + 10000 +); +const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max( + Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000, + 10000 +); + +function shouldCachePlanningUploadResponse(body = {}) { + if (!body || body.error || body.success === false) { + return false; + } + + const payload = body.data || body; + if (payload?.pending || payload?.cache?.backgroundRefresh) { + return false; + } + + return true; +} + +function shouldHandlePlanningUploadPageCache(req) { + // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。 + // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。 + // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。 + // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。 + // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。 + if ( + req.path === '/' + || req.path.startsWith('/table/may_product_data') + || req.path.startsWith('/analysis/may-channel-insight') + || req.path.startsWith('/analysis/ops-b-incentive') + || req.path.startsWith('/selection-fission') + ) { + return false; + } + + return true; +} + +const analysisPageCache = createPageDataCache({ + namespace: 'analysis', + redisTtlSeconds: 300, +}); +const channelPageCache = createPageDataCache({ + namespace: 'channel', + redisTtlSeconds: 300, + // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。 + shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path) + && !req.path.startsWith('/export-jobs'), +}); +const planningUploadPageCache = createPageDataCache({ + namespace: 'planning-upload', + version: 'v27-bypass-may-snapshot-pages-20260423', + redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS, + shouldHandleRequest: shouldHandlePlanningUploadPageCache, + shouldCacheBody: shouldCachePlanningUploadResponse, +}); +let serverMode = 'install'; + +function isStartupPrewarmEnabled() { + return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true'; +} + +function runGuardedPrewarm(label, handler) { + return runHeavyDatabaseTask(`prewarm:${label}`, handler); +} + +function withTimeout(promise, timeoutMs, fallback) { + let timer = null; + return Promise.race([ + promise.finally(() => { + if (timer) { + clearTimeout(timer); + } + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), timeoutMs); + if (typeof timer.unref === 'function') { + timer.unref(); + } + }), + ]); +} + +function hasCompleteCloudEnvConfig() { + return cloudDb.hasCompleteEnvConfig(); +} + +function getCloudEnvConfig() { + const config = cloudDb.getEnvBusinessDatabaseConfig(); + return { + driver: config.driver || 'mssql', + host: config.host, + port: config.port || 1433, + database: config.database, + user: config.user, + password: config.password, + ssl: Boolean(config.ssl), + }; +} + +function getBootstrapCloudConfig() { + return { + driver: 'mysql', + host: process.env.LOCAL_DB_HOST, + port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10), + database: process.env.LOCAL_DB_NAME, + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + ssl: false, + bootstrap: true, + }; +} + +// ===================== +// 从数据库获取云数据库配置 +// ===================== +async function getCloudDbConfig() { + let localPool; + try { + // 先连接本地数据库读取配置 + localPool = mysql.createPool({ + host: process.env.LOCAL_DB_HOST, + port: parseInt(process.env.LOCAL_DB_PORT || '3306'), + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + database: process.env.LOCAL_DB_NAME, + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: 2, + maxIdle: 2, + idleTimeout: 60000, + queueLimit: 10, + connectTimeout: 5000, + enableKeepAlive: true, + keepAliveInitialDelay: 0, + }); + + const [rows] = await localPool.query( + 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1', + ['active'] + ); + + if (rows.length > 0) { + const config = rows[0]; + if (config.use_env_config === 1 || config.use_env_config === true) { + if (hasCompleteCloudEnvConfig()) { + console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)'); + return getCloudEnvConfig(); + } + console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置'); + } + + console.log('📌 使用数据库配置(来自 database_configs 表)'); + return { + driver: config.driver || 'mysql', + host: config.host, + port: config.port || 3306, + database: config.database_name, + user: config.username, + password: config.password, + ssl: config.ssl === 1, + }; + } + } catch (error) { + console.warn('⚠️ 无法从数据库读取配置:', error.message); + } finally { + if (localPool) { + await localPool.end().catch((error) => { + console.warn('⚠️ 关闭系统库临时连接池失败:', error.message); + }); + } + } + + // 回退到环境变量配置 + if (hasCompleteCloudEnvConfig()) { + console.log('📌 使用环境变量配置(CLOUD_DB_*)'); + return getCloudEnvConfig(); + } + + console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式'); + return getBootstrapCloudConfig(); +} + +function applyCommonMiddleware(app) { + app.set('trust proxy', 1); + app.use(cors()); + app.use(express.json({ + limit: '15mb', + verify: (req, _res, buffer) => { + req.rawBody = buffer.toString('utf8'); + }, + })); +} + +function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) { + app.get('/api/admin/system-config', async (_req, res) => { + const now = Date.now(); + const cachedConfig = getPublicSystemConfigCache(now); + if (cachedConfig) { + return res.json(cachedConfig); + } + + try { + const configs = await Promise.race([ + database.local.query('SELECT config_key, config_value, config_type FROM system_config'), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS); + }), + ]); + const result = {}; + filterPublicSystemConfigs(configs).forEach((config) => { + if (config.config_type === 'boolean') { + result[config.config_key] = config.config_value === 'true' || config.config_value === '1'; + } else if (config.config_type === 'number') { + result[config.config_key] = Number(config.config_value); + } else { + result[config.config_key] = config.config_value; + } + }); + setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now); + res.json(result); + } catch (error) { + const staleConfig = getStalePublicSystemConfigCache(); + if (staleConfig) { + return res.json(staleConfig); + } + + if (serverMode === 'install' || serverMode === 'database_unavailable') { + return res.json({ + site_name: fallbackConfig.siteName, + site_description: fallbackConfig.siteDescription, + logo: '', + }); + } + + console.warn('获取系统配置超时或失败,返回兜底配置:', error.message); + return res.json({ + site_name: fallbackConfig.siteName, + site_description: fallbackConfig.siteDescription, + logo: '', + }); + } + }); +} + +function registerInstallModeRoutes(app, installStatus) { + const unavailableHandler = (_req, res) => { + res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' }); + }; + + app.get('/health', async (_req, res) => { + const status = await getInstallStatus(); + res.json({ + status: 'install_required', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + install: status, + }); + }); + + app.use('/api/install', installRoutes); + registerPublicSystemConfigRoute(app); + + app.get('/api/auth/providers', (_req, res) => { + res.json({ + authBaseUrl: '', + dingtalkEnabled: false, + googleEnabled: false, + installRequired: true, + }); + }); + app.get('/api/auth/dingtalk/login', unavailableHandler); + app.get('/api/auth/google/login', unavailableHandler); + app.post('/api/auth/login', unavailableHandler); + app.post('/api/auth/callback', unavailableHandler); + app.get('/api/auth/verify', (_req, res) => { + res.status(401).json({ valid: false, installRequired: true }); + }); + + app.use('/api/admin', (_req, res) => { + res.status(503).json({ + error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号', + }); + }); + + console.log('='.repeat(50)); + console.log('🛠️ 系统已进入首次安装模式'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`); + console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`); + console.log('='.repeat(50)); +} + +async function startInstallServer(installStatus) { + serverMode = 'install'; + const app = express(); + applyCommonMiddleware(app); + registerInstallModeRoutes(app, installStatus); + + const port = parseInt(process.env.PORT || '4000', 10); + await new Promise((resolve) => { + app.listen(port, resolve); + }); +} + +function registerDatabaseUnavailableRoutes(app, installStatus) { + const unavailableHandler = (_req, res) => { + res.status(503).json({ + error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务', + databaseUnavailable: true, + installRequired: false, + }); + }; + + app.get('/health', async (_req, res) => { + const status = await getInstallStatus(); + res.status(503).json({ + status: 'database_unavailable', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + install: status, + }); + }); + + app.use('/api/install', installRoutes); + registerPublicSystemConfigRoute(app); + + app.get('/api/auth/providers', (_req, res) => { + res.json({ + authBaseUrl: '', + dingtalkEnabled: false, + googleEnabled: false, + installRequired: false, + databaseUnavailable: true, + }); + }); + app.get('/api/auth/verify', (_req, res) => { + res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false }); + }); + app.post('/api/auth/login', unavailableHandler); + app.post('/api/auth/callback', unavailableHandler); + app.get('/api/auth/dingtalk/login', unavailableHandler); + app.get('/api/auth/google/login', unavailableHandler); + app.use('/api', unavailableHandler); + + console.log('='.repeat(50)); + console.log('🚧 系统数据库暂时无法连接,已进入降级模式'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`); + console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务'); + console.log('='.repeat(50)); +} + +async function startDatabaseUnavailableServer(installStatus) { + serverMode = 'database_unavailable'; + const app = express(); + applyCommonMiddleware(app); + registerDatabaseUnavailableRoutes(app, installStatus); + + const port = parseInt(process.env.PORT || '4000', 10); + await new Promise((resolve) => { + app.listen(port, resolve); + }); +} + +async function createFullModeApp(cloudConfig) { + const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql'; + console.log(`📊 数据库类型: ${dbType}`); + + const port = parseInt(process.env.PORT || '4000', 10); + + if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') { + console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务'); + const app = express(); + await new Promise((resolve) => { + app.listen(port, resolve); + }); + + return { + app, + cubeMode: 'disabled', + cubeError: 'DISABLE_CUBEJS=true', + }; + } + + try { + const cubejsServer = new CubejsServer({ + schemaPath: 'model', + dbType: dbType, + logger: (msg, params) => { + console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : ''); + }, + driverFactory: () => { + // 根据 driver 类型选择驱动 + if (cloudConfig.driver === 'mssql') { + const MSSQLDriver = require('@cubejs-backend/mssql-driver'); + return new MSSQLDriver({ + server: cloudConfig.host, + port: cloudConfig.port, + database: cloudConfig.database, + user: cloudConfig.user, + password: cloudConfig.password, + options: { + encrypt: cloudConfig.ssl, + trustServerCertificate: true, + }, + }); + } + + // 默认使用 MySQL + const MySQLDriver = require('@cubejs-backend/mysql-driver'); + return new MySQLDriver({ + database: cloudConfig.database, + host: cloudConfig.host, + port: cloudConfig.port, + user: cloudConfig.user, + password: cloudConfig.password, + ssl: cloudConfig.ssl, + }); + }, + apiSecret: process.env.CUBEJS_API_SECRET, + scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER, + jwt: { + key: process.env.CUBEJS_SECRET, + algorithms: ['HS256'], + }, + }); + + const { app } = await cubejsServer.listen(); + return { + app, + cubeMode: 'cube', + cubeError: null, + }; + } catch (error) { + console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message); + const app = express(); + await new Promise((resolve) => { + app.listen(port, resolve); + }); + + return { + app, + cubeMode: 'express-fallback', + cubeError: error.message, + }; + } +} + +// ===================== +// 启动服务 +// ===================== +async function startServer(options = {}) { + const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true'; + const { skipLocalSchema = envSkipLocalSchema } = options; + serverMode = 'full'; + if (skipLocalSchema) { + console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API'); + } else { + await ensureLocalSchema(); + } + + if (shouldRequireImageSearchCredentials()) { + const credentialStatus = await verifyImageSearchCredentials(); + console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`); + } + + // 获取云数据库配置 + const cloudConfig = await getCloudDbConfig(); + if (cloudConfig.bootstrap) { + console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); + } else { + console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); + } + + // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。 + const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig); + + applyCommonMiddleware(cubejsApp); + + // ===================== + // 健康检查 (多数据源) + // ===================== + cubejsApp.get('/health', async (_req, res) => { + const [systemTest, businessTest, redisTest] = await Promise.all([ + withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }), + withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }), + withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }), + ]); + + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + databases: { + system: systemTest, + local: systemTest, + cloud: businessTest, + }, + services: { + redis: redisTest, + cube: { + success: cubeMode === 'cube', + mode: cubeMode, + message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`, + }, + }, + }); + }); + + // ===================== + // 认证 API + // ===================== + cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders); + cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin); + cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin); + cubejsApp.post('/api/auth/login', require('./api/auth').login); + cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview); + cubejsApp.post('/api/auth/callback', require('./api/auth').callback); + cubejsApp.get('/api/auth/verify', require('./api/auth').verify); + + // ===================== + // 公开访问的 API(无需认证) + // ===================== + cubejsApp.use('/api/install', installRoutes); + cubejsApp.use('/api/sso', thirdPartySsoRoutes); + cubejsApp.use('/api/desktop-client', desktopClientRoutes); + cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes); + cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes); + cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes); + cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes); + cubejsApp.use('/api/campaign', campaignCloudRoutes); + cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes); + cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes); + cubejsApp.use('/api/external/presale', presaleExternalRoutes); + + // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo) + registerPublicSystemConfigRoute(cubejsApp); + + // ===================== + // 需要认证的 API + // ===================== + cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes); + cubejsApp.use('/api/admin', authMiddleware, adminRoutes); + cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes); + cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes); + cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes); + cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); + cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); + cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); + cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); + cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); + cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); + cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); + cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); + cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); + + // ===================== + // 数据分析 API(公开访问,可根据需要添加认证) + // ===================== + cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes); + + // ===================== + // 数据刷新 API(需要认证) + // ===================== + cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => { + try { + const { date } = req.body; + console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`); + const result = await manualRefresh(date); + res.json({ success: true, ...result }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // ===================== + // 重新加载云数据库配置 API + // ===================== + cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => { + try { + console.log('🔄 正在重新加载云数据库配置...'); + res.json({ + success: true, + message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang' + }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } + }); + + console.log('='.repeat(50)); + console.log('🚀 后端服务已启动'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(cubeMode === 'cube' + ? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api` + : '📊 Cube.js API: 已降级不可用,业务 API 继续运行'); + + // 测试数据库连接 + console.log('\n📦 数据库连接测试:'); + const [systemTest, businessTest, redisTest] = await Promise.all([ + withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }), + withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }), + withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }), + ]); + console.log(` [系统] ${systemTest.message}`); + console.log(` [业务] ${businessTest.message}`); + console.log(` [缓存] ${redisTest.message}`); + console.log('='.repeat(50)); + + if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') { + console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理'); + return; + } + + startScheduler(); + startStockSelectionSchedulers(); + startTaobaoProductEmbeddingSchedulers(); + startPddSkuDimensionSnapshotSchedulers(); + startPlanningMayProductSnapshotSchedulers(); + startPlanningDingWorkflowSyncSchedulers(); + startOpsBIncentiveSnapshotSchedulers(); + startGoodsFirstSeenScheduler(); + startAlibaba1688ImageSearchExpiryScheduler(); + startMkhReplenishAlertScheduler(); + + // Keep the first selection-pool page request from paying the remote schema + // inspection round-trip over the planning database tunnel. + setTimeout(() => { + warmSelectionFissionReadSchema() + .then(() => console.log('[SelectionFission] 列表读取结构预热完成')) + .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message)); + }, 1000); + + if (isStartupPrewarmEnabled()) { + setTimeout(() => { + if (typeof prewarmChannelHotData === 'function') { + runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => { + console.warn('⚠️ 渠道默认查询预热失败:', error.message); + }); + } + }, 8000); + + setTimeout(() => { + if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { + runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { + console.warn('⚠️ 分析页默认查询预热失败:', error.message); + }); + } + }, 10000); + + + + if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { + const analysisPrewarmTimer = setInterval(() => { + runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { + console.warn('⚠️ 分析页默认查询定时预热失败:', error.message); + }); + }, 10 * 60 * 1000); + + if (typeof analysisPrewarmTimer.unref === 'function') { + analysisPrewarmTimer.unref(); + } + } + + + } else { + console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启'); + } + + if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') { + if (typeof prewarmChannelIdDefaultSnapshots === 'function') { + setTimeout(() => { + runGuardedPrewarm( + 'channel-id-analysis-resilience', + () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message); + }); + }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS); + } + + if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') { + setTimeout(() => { + runGuardedPrewarm( + 'channel-id-dashboard-resilience', + () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message); + }); + }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000); + } + + setTimeout(() => { + runGuardedPrewarm( + 'channel-stock-monitor-resilience', + () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message); + }); + }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS); + } + + if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false' + && typeof prewarmBrandAnalysisDefault === 'function') { + setTimeout(() => { + runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault()) + .then((result) => { + console.log( + `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}` + ); + }) + .catch((error) => { + console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message); + }); + }, BRAND_ANALYSIS_PREWARM_DELAY_MS); + } + + const cron = require('node-cron'); + + if (typeof prewarmChannelHotData === 'function') { + cron.schedule('0 1 * * *', async () => { + console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...'); + await runGuardedPrewarm( + 'channel-hot-data:daily', + () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true }) + ); + }, { + timezone: 'Asia/Shanghai', + }); + console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行'); + } + + if (process.env.NODE_ENV === 'production') { + const alertChecker = require('./services/alertChecker'); + + cron.schedule('*/5 * * * *', async () => { + console.log('🔔 执行预警检查...'); + await alertChecker.checkAllRules(); + }); + } +} + +async function bootstrap() { + const installStatus = await getInstallStatus(); + if (installStatus.required) { + await startInstallServer(installStatus); + return; + } + + if (installStatus.databaseUnavailable) { + console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`); + await startServer({ skipLocalSchema: true }); + return; + } + + await startServer(); +} + +bootstrap().catch((error) => { + console.error('❌ 服务启动失败:', error); + process.exit(1); +}); + +async function shutdownGracefully() { + await Promise.allSettled([ + cloudDb.close(), + jianSunDb.close(), + database.close(), + redis.close(), + ]); +} + +// 优雅关闭 +process.on('SIGTERM', async () => { + console.log('正在关闭服务...'); + await shutdownGracefully(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('正在关闭服务...'); + await shutdownGracefully(); + process.exit(0); +}); -- 2.47.2 From 570666f772d61c61081b29de47094a1571806195 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:50 +0800 Subject: [PATCH 10/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From e579f7356028a96997edff63f2b953ad6b163cd0 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:51 +0800 Subject: [PATCH 11/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From a90c2fd4f0811258f6dba310f6758c4382e9b3f1 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:52 +0800 Subject: [PATCH 12/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From fce269828fe0d4e5d24d97132721ed8a4ee676d1 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:53 +0800 Subject: [PATCH 13/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From 9a77d15448ec8e83aa2c899650b1c8629d542bfe Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:54 +0800 Subject: [PATCH 14/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From 9fd0bcaf4bc785d123db418c04df089d91f65d04 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:55 +0800 Subject: [PATCH 15/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From 2bd5dce3d7dd46505eb8c60860ed11a7576bdb2f Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:56 +0800 Subject: [PATCH 16/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/config/sidebarNavigation.js | 396 ++++++++++++----------- 1 file changed, 201 insertions(+), 195 deletions(-) diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 63a6520..5257bb9 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -1,195 +1,201 @@ -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)) -); +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: '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: '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)) +); -- 2.47.2 From 39c7933d2dfc621d4d29328c554f55860757b52f Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:57 +0800 Subject: [PATCH 17/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From 9e2f26b1c91bba2992328337cd269678ec55d3f7 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:58 +0800 Subject: [PATCH 18/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/sidebarPages.js | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 4545a75..3e72ea9 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -1,12 +1,13 @@ -const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false }; -const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false }; - -export const SIDEBAR_PAGES = [ - { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, - { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, -]; - -export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { - acc[item.pageKey] = item; - return acc; -}, {}); +const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false }; +const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false }; + +export const SIDEBAR_PAGES = [ + { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, + { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, + { pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, +]; + +export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { + acc[item.pageKey] = item; + return acc; +}, {}); -- 2.47.2 From cd936f5e92d466862c8ca1cb43cc415f8cc68766 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:00 +0800 Subject: [PATCH 19/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/layouts/BasicLayout.jsx | 2651 +++++++++++++------------- 1 file changed, 1326 insertions(+), 1325 deletions(-) diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 6cb7ddc..eabe42e 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -1,1325 +1,1326 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { - AppstoreOutlined, - CloudOutlined, - DashboardOutlined, - DesktopOutlined, - DownOutlined, - ExperimentOutlined, - FundProjectionScreenOutlined, - LineChartOutlined, - LogoutOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, - MessageOutlined, - MoonOutlined, - RightOutlined, - RobotOutlined, - SettingOutlined, - ShoppingOutlined, - TableOutlined, - TeamOutlined, - UserOutlined, - SunOutlined, -} from '@ant-design/icons'; -import { Dropdown, Switch } from 'antd'; -import { verifyToken } from '../api/auth'; -import { getSetupStatus, getSystemConfig } from '../api/admin'; -import { getInstallStatus } from '../api/install'; -import ErrorBoundary from '../components/ErrorBoundary'; -import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; -import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; -import { - canRestoreDesktopSessionImmediately, - isDesktopEmbedSession, - markDesktopEmbedSession, - requestDesktopSessionRenewal, -} from '../utils/desktopEmbedSession'; - -const AUTH_USER_STORAGE_KEY = 'auth_user'; -const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; -const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; -const PLANNING_HOME_URL = '/planning/dev-style-preview'; -const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; -const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; -const readSidebarDarkModePreference = () => { - try { - const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); - if (value === 'dark') return true; - if (value === 'light') return false; - } catch { - // Local persistence is optional in restricted browser contexts. - } - return null; -}; - -const SIDEBAR_ICON_MAP = { - appstore: AppstoreOutlined, - cloud: CloudOutlined, - dashboard: DashboardOutlined, - desktop: DesktopOutlined, - experiment: ExperimentOutlined, - 'line-chart': LineChartOutlined, - message: MessageOutlined, - projection: FundProjectionScreenOutlined, - robot: RobotOutlined, - setting: SettingOutlined, - shopping: ShoppingOutlined, - table: TableOutlined, - team: TeamOutlined, -}; - -const SIDEBAR_ROUTE_MAP = { - 'dashboard-preview': { path: '/planning/dev-style-preview' }, - 'marketing-events': { - path: '/planning/operation/marketing-events', - activePath: '/planning/operation/marketing-events', - matchMode: 'prefix', - }, - 'promotion-module': { path: '/planning/operation/promotion-module' }, - 'product-monitoring': { path: '/planning/operation/product-monitoring' }, - 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, - 'product-analysis': { path: '/planning/operation/product-analysis' }, - 'work-orders': { path: '/planning/operation/work-orders' }, - 'modular-stock-monitor-accessory': { - path: '/planning/operation/modular-stock-monitor', - activePath: '/planning/operation/modular-stock-monitor', - matchMode: 'prefix', - }, - 'mkh-bom-data': { - path: '/planning/operation/mkh-bom-data', - activePath: '/planning/operation/mkh-bom-data', - matchMode: 'prefix', - }, - settings: { path: '/settings' }, - 'online-desktop-users': { path: '/settings/online-desktop-users' }, - 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, -}; - - -const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { - if (entry.requiresAdmin && !canUseSettings) return []; - if (entry.type === 'section') return [{ ...entry }]; - - const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; - const routeMatches = (itemRoute = {}, pathname = '') => { - const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; - if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { - return false; - } - - const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; - if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { - return true; - } - - const matchPath = itemRoute.activePath || itemRoute.path; - if (!matchPath) return false; - return itemRoute.matchMode === 'prefix' - ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) - : pathname === matchPath; - }; - const buildItem = (item) => { - if (item.requiresAdmin && !canUseSettings) return null; - const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; - return { - ...item, - icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, - path: itemRoute.path, - external: Boolean(itemRoute.externalUrl), - onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, - match: (path) => routeMatches(itemRoute, path), - }; - }; - - const normalized = { - ...entry, - icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, - path: route.path, - external: Boolean(route.externalUrl), - onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, - match: (path) => routeMatches(route, path), - }; - - if (entry.type === 'group') { - normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); - } - - return [normalized]; -}); - -function parseJwtPayload(token) { - if (!token) { - return null; - } - - try { - const [, payload] = token.split('.'); - if (!payload) { - return null; - } - - const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); - return JSON.parse(window.atob(padded)); - } catch { - return null; - } -} - -function isExpiredJwtPayload(payload) { - const exp = Number(payload?.exp || 0); - return exp > 0 && Math.floor(Date.now() / 1000) >= exp; -} - -function readStoredAuthUser(tokenSub) { - try { - const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); - if (!raw) { - return null; - } - - const storedUser = JSON.parse(raw); - const storedSub = storedUser?.sub ?? storedUser?.id; - return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; - } catch { - return null; - } -} - -function clearAuthSession() { - localStorage.removeItem('token'); - localStorage.removeItem(AUTH_USER_STORAGE_KEY); -} - -function looksLikeMojibake(text) { - return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); -} - -function repairMojibake(text) { - if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { - return text; - } - - try { - const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); - const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); - return repaired || text; - } catch { - return text; - } -} - -function normalizeDisplayName(...candidates) { - const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); - return repairMojibake(rawName?.trim() || '个人中心'); -} - -function buildFallbackUserFromToken(token) { - const payload = parseJwtPayload(token); - if (!payload?.sub || isExpiredJwtPayload(payload)) { - return null; - } - - const storedUser = readStoredAuthUser(payload.sub); - return { - sub: payload.sub, - username: payload.username || '', - name: payload.name || payload.username || '当前用户', - email: payload.email || '', - role: payload.role || storedUser?.role || 'user', - picture: payload.picture || storedUser?.picture || '', - authSource: payload.authSource || storedUser?.authSource || 'local', - departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, - departmentName: payload.departmentName || storedUser?.departmentName || '', - allowedPageKeys: Array.isArray(payload.allowedPageKeys) - ? payload.allowedPageKeys - : Array.isArray(storedUser?.allowedPageKeys) - ? storedUser.allowedPageKeys - : [], - }; -} - -function canFallbackToJwtPayload(error, fallbackUser) { - if (!fallbackUser) { - return false; - } - - if (fallbackUser.authSource === 'local-preview') { - return true; - } - - const status = Number(error?.response?.status || 0); - return !status || status >= 500; -} - -const BasicLayout = () => { - const location = useLocation(); - const navigate = useNavigate(); - const [user, setUser] = useState(null); - const [authReady, setAuthReady] = useState(false); - const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); - const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); - const [sidebarCollapsed, setSidebarCollapsed] = useState( - () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault - ); - const [sidebarDarkMode, setSidebarDarkMode] = useState( - () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault - ); - const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( - DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); - - const pathname = location.pathname; - const isImmersivePlanningRoute = false; - const canUseSettings = user?.role === 'admin'; - const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); - const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; - - const sidebarSections = useMemo( - () => buildSidebarSections(sidebarConfig, canUseSettings), - [canUseSettings, sidebarConfig] - ); - useEffect(() => { - const loadSiteConfig = async () => { - try { - const config = await getSystemConfig(); - const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; - const nextLogo = config?.logo || FALLBACK_LOGO; - const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); - - setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); - setSidebarConfig(nextSidebarConfig); - setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); - setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); - setExpandedSidebarKeys( - nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); - document.title = nextSiteName; - - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = nextLogo; - } catch { - document.title = FALLBACK_SITE_NAME; - } - }; - - loadSiteConfig(); - }, []); - - useEffect(() => { - const handleSiteConfigUpdated = (event) => { - const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; - const nextLogo = event.detail?.logo; - - setSiteConfig((current) => ({ - siteName: nextSiteName, - logo: nextLogo || current.logo, - })); - document.title = nextSiteName; - - if (nextLogo) { - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = nextLogo; - } - }; - - window.addEventListener('site-config-updated', handleSiteConfigUpdated); - return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); - }, []); - - useEffect(() => { - const checkAuth = async () => { - markDesktopEmbedSession(); - const token = localStorage.getItem('token'); - const fallbackUser = buildFallbackUserFromToken(token); - - if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { - setUser(fallbackUser); - setAuthReady(true); - - try { - const verification = await verifyToken(); - if (!verification?.valid || !verification.user) { - clearAuthSession(); - requestDesktopSessionRenewal('web-session-invalid'); - return; - } - - const resolvedUser = verification.user; - localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); - setUser(resolvedUser); - - if (resolvedUser.role === 'admin') { - const setup = await getSetupStatus().catch((error) => { - console.warn('Setup status unavailable, continue with current session:', error); - return null; - }); - if (setup?.shouldShowWizard) { - navigate('/setup', { replace: true }); - } - } - } catch (error) { - if (!canFallbackToJwtPayload(error, fallbackUser)) { - clearAuthSession(); - requestDesktopSessionRenewal('web-session-invalid'); - } - } - return; - } - - try { - const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); - const verificationPromise = verifyToken() - .then((res) => ({ res, error: null })) - .catch((error) => ({ res: null, error })); - const setupStatusPromise = fallbackUser?.role === 'admin' - ? getSetupStatus().catch((error) => { - console.warn('Setup status unavailable, continue with current session:', error); - return null; - }) - : null; - const [installStatus, verification] = await Promise.all([ - installStatusPromise, - verificationPromise, - ]); - if (installStatus?.required && !fallbackUser) { - clearAuthSession(); - navigate('/install', { replace: true }); - return; - } - - let resolvedUser = null; - const verifyError = verification.error; - if (verification.res?.valid) { - resolvedUser = verification.res.user; - } else if (verifyError) { - console.warn('Token verification failed:', verifyError); - } - - if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { - resolvedUser = fallbackUser; - } - - if (resolvedUser) { - localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); - if (resolvedUser.role === 'admin') { - try { - const setup = setupStatusPromise - ? await setupStatusPromise - : await getSetupStatus(); - if (setup?.shouldShowWizard) { - navigate('/setup', { replace: true }); - return; - } - } catch (error) { - console.warn('Setup status unavailable, continue with current session:', error); - } - } - - setUser(resolvedUser); - } else if (installStatus?.required) { - clearAuthSession(); - navigate('/install', { replace: true }); - } else { - clearAuthSession(); - if (!requestDesktopSessionRenewal('web-session-missing')) { - navigate('/login'); - } - } - } catch (error) { - console.error(error); - if (fallbackUser) { - setUser(fallbackUser); - } else { - clearAuthSession(); - if (!requestDesktopSessionRenewal('web-session-invalid')) { - navigate('/login'); - } - } - } finally { - setAuthReady(true); - } - }; - - checkAuth(); - }, [navigate]); - - useEffect(() => { - if (!authReady || !user) { - return; - } - - if (pathname === '/' || pathname === '/dashboard') { - navigate(PLANNING_HOME_URL, { replace: true }); - return; - } - - if (pathname.startsWith('/settings') && !canUseSettings) { - navigate(PLANNING_HOME_URL, { replace: true }); - return; - } - - const isAllowedPath = ( - pathname === '/planning' - || pathname.startsWith('/planning/') - || pathname === '/settings' - || pathname.startsWith('/settings/') - ); - - if (!isAllowedPath) { - navigate(PLANNING_HOME_URL, { replace: true }); - } - }, [authReady, canUseSettings, navigate, pathname, user]); - - const handleLogout = () => { - clearAuthSession(); - navigate('/login'); - }; - - const userMenu = [ - canUseSettings - ? { - key: 'settings', - icon: , - label: '系统设置', - onClick: () => navigate('/settings'), - } - : null, - { - key: 'logout', - icon: , - label: '退出登录', - onClick: handleLogout, - }, - ].filter(Boolean); - - const isSidebarEntryActive = (entry) => { - if (!entry) return false; - if (entry.match?.(pathname)) return true; - return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); - }; - - const toggleSidebarGroup = (key) => { - setSidebarCollapsed(false); - setExpandedSidebarKeys((keys) => ( - keys.includes(key) - ? keys.filter((item) => item !== key) - : [...keys, key] - )); - }; - - const handleSidebarLeafClick = (item) => { - if (item.external) { - item.onClick?.(); - return; - } - if (item.path) { - navigate(item.path); - } - }; - - const handleSidebarDarkModeChange = (checked) => { - setSidebarDarkMode(checked); - try { - localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); - } catch { - // Keep the current-session preference when persistence is unavailable. - } - }; - - if (!authReady) { - return ; - } - - return ( -
- - -
- - - -
- - -
- ); -}; - -export default BasicLayout; +import React, { useEffect, useMemo, useState } from 'react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { + AppstoreOutlined, + CloudOutlined, + DashboardOutlined, + DesktopOutlined, + DownOutlined, + ExperimentOutlined, + FundProjectionScreenOutlined, + LineChartOutlined, + LogoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + MessageOutlined, + MoonOutlined, + RightOutlined, + RobotOutlined, + SettingOutlined, + ShoppingOutlined, + TableOutlined, + TeamOutlined, + UserOutlined, + SunOutlined, +} from '@ant-design/icons'; +import { Dropdown, Switch } from 'antd'; +import { verifyToken } from '../api/auth'; +import { getSetupStatus, getSystemConfig } from '../api/admin'; +import { getInstallStatus } from '../api/install'; +import ErrorBoundary from '../components/ErrorBoundary'; +import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; +import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; +import { + canRestoreDesktopSessionImmediately, + isDesktopEmbedSession, + markDesktopEmbedSession, + requestDesktopSessionRenewal, +} from '../utils/desktopEmbedSession'; + +const AUTH_USER_STORAGE_KEY = 'auth_user'; +const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; +const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; +const PLANNING_HOME_URL = '/planning/dev-style-preview'; +const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; +const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; +const readSidebarDarkModePreference = () => { + try { + const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); + if (value === 'dark') return true; + if (value === 'light') return false; + } catch { + // Local persistence is optional in restricted browser contexts. + } + return null; +}; + +const SIDEBAR_ICON_MAP = { + appstore: AppstoreOutlined, + cloud: CloudOutlined, + dashboard: DashboardOutlined, + desktop: DesktopOutlined, + experiment: ExperimentOutlined, + 'line-chart': LineChartOutlined, + message: MessageOutlined, + projection: FundProjectionScreenOutlined, + robot: RobotOutlined, + setting: SettingOutlined, + shopping: ShoppingOutlined, + table: TableOutlined, + team: TeamOutlined, +}; + +const SIDEBAR_ROUTE_MAP = { + 'dashboard-preview': { path: '/planning/dev-style-preview' }, + 'marketing-events': { + path: '/planning/operation/marketing-events', + activePath: '/planning/operation/marketing-events', + matchMode: 'prefix', + }, + 'promotion-module': { path: '/planning/operation/promotion-module' }, + 'product-monitoring': { path: '/planning/operation/product-monitoring' }, + 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, + 'product-analysis': { path: '/planning/operation/product-analysis' }, + 'work-orders': { path: '/planning/operation/work-orders' }, + 'modular-stock-monitor-accessory': { + path: '/planning/operation/modular-stock-monitor', + activePath: '/planning/operation/modular-stock-monitor', + matchMode: 'prefix', + }, + 'mkh-bom-data': { + path: '/planning/operation/mkh-bom-data', + activePath: '/planning/operation/mkh-bom-data', + matchMode: 'prefix', + }, + 'daily-inventory': { path: '/planning/operation/daily-inventory' }, + settings: { path: '/settings' }, + 'online-desktop-users': { path: '/settings/online-desktop-users' }, + 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, +}; + + +const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { + if (entry.requiresAdmin && !canUseSettings) return []; + if (entry.type === 'section') return [{ ...entry }]; + + const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; + const routeMatches = (itemRoute = {}, pathname = '') => { + const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; + if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return false; + } + + const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; + if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return true; + } + + const matchPath = itemRoute.activePath || itemRoute.path; + if (!matchPath) return false; + return itemRoute.matchMode === 'prefix' + ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) + : pathname === matchPath; + }; + const buildItem = (item) => { + if (item.requiresAdmin && !canUseSettings) return null; + const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; + return { + ...item, + icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, + path: itemRoute.path, + external: Boolean(itemRoute.externalUrl), + onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, + match: (path) => routeMatches(itemRoute, path), + }; + }; + + const normalized = { + ...entry, + icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, + path: route.path, + external: Boolean(route.externalUrl), + onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, + match: (path) => routeMatches(route, path), + }; + + if (entry.type === 'group') { + normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); + } + + return [normalized]; +}); + +function parseJwtPayload(token) { + if (!token) { + return null; + } + + try { + const [, payload] = token.split('.'); + if (!payload) { + return null; + } + + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); + return JSON.parse(window.atob(padded)); + } catch { + return null; + } +} + +function isExpiredJwtPayload(payload) { + const exp = Number(payload?.exp || 0); + return exp > 0 && Math.floor(Date.now() / 1000) >= exp; +} + +function readStoredAuthUser(tokenSub) { + try { + const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); + if (!raw) { + return null; + } + + const storedUser = JSON.parse(raw); + const storedSub = storedUser?.sub ?? storedUser?.id; + return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; + } catch { + return null; + } +} + +function clearAuthSession() { + localStorage.removeItem('token'); + localStorage.removeItem(AUTH_USER_STORAGE_KEY); +} + +function looksLikeMojibake(text) { + return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); +} + +function repairMojibake(text) { + if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { + return text; + } + + try { + const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); + const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); + return repaired || text; + } catch { + return text; + } +} + +function normalizeDisplayName(...candidates) { + const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); + return repairMojibake(rawName?.trim() || '个人中心'); +} + +function buildFallbackUserFromToken(token) { + const payload = parseJwtPayload(token); + if (!payload?.sub || isExpiredJwtPayload(payload)) { + return null; + } + + const storedUser = readStoredAuthUser(payload.sub); + return { + sub: payload.sub, + username: payload.username || '', + name: payload.name || payload.username || '当前用户', + email: payload.email || '', + role: payload.role || storedUser?.role || 'user', + picture: payload.picture || storedUser?.picture || '', + authSource: payload.authSource || storedUser?.authSource || 'local', + departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, + departmentName: payload.departmentName || storedUser?.departmentName || '', + allowedPageKeys: Array.isArray(payload.allowedPageKeys) + ? payload.allowedPageKeys + : Array.isArray(storedUser?.allowedPageKeys) + ? storedUser.allowedPageKeys + : [], + }; +} + +function canFallbackToJwtPayload(error, fallbackUser) { + if (!fallbackUser) { + return false; + } + + if (fallbackUser.authSource === 'local-preview') { + return true; + } + + const status = Number(error?.response?.status || 0); + return !status || status >= 500; +} + +const BasicLayout = () => { + const location = useLocation(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [authReady, setAuthReady] = useState(false); + const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); + const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault + ); + const [sidebarDarkMode, setSidebarDarkMode] = useState( + () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault + ); + const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( + DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + + const pathname = location.pathname; + const isImmersivePlanningRoute = false; + const canUseSettings = user?.role === 'admin'; + const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); + const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; + + const sidebarSections = useMemo( + () => buildSidebarSections(sidebarConfig, canUseSettings), + [canUseSettings, sidebarConfig] + ); + useEffect(() => { + const loadSiteConfig = async () => { + try { + const config = await getSystemConfig(); + const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; + const nextLogo = config?.logo || FALLBACK_LOGO; + const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); + + setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); + setSidebarConfig(nextSidebarConfig); + setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); + setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); + setExpandedSidebarKeys( + nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + document.title = nextSiteName; + + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } catch { + document.title = FALLBACK_SITE_NAME; + } + }; + + loadSiteConfig(); + }, []); + + useEffect(() => { + const handleSiteConfigUpdated = (event) => { + const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; + const nextLogo = event.detail?.logo; + + setSiteConfig((current) => ({ + siteName: nextSiteName, + logo: nextLogo || current.logo, + })); + document.title = nextSiteName; + + if (nextLogo) { + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } + }; + + window.addEventListener('site-config-updated', handleSiteConfigUpdated); + return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); + }, []); + + useEffect(() => { + const checkAuth = async () => { + markDesktopEmbedSession(); + const token = localStorage.getItem('token'); + const fallbackUser = buildFallbackUserFromToken(token); + + if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { + setUser(fallbackUser); + setAuthReady(true); + + try { + const verification = await verifyToken(); + if (!verification?.valid || !verification.user) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + return; + } + + const resolvedUser = verification.user; + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + setUser(resolvedUser); + + if (resolvedUser.role === 'admin') { + const setup = await getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + } + } + } catch (error) { + if (!canFallbackToJwtPayload(error, fallbackUser)) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + } + } + return; + } + + try { + const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); + const verificationPromise = verifyToken() + .then((res) => ({ res, error: null })) + .catch((error) => ({ res: null, error })); + const setupStatusPromise = fallbackUser?.role === 'admin' + ? getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }) + : null; + const [installStatus, verification] = await Promise.all([ + installStatusPromise, + verificationPromise, + ]); + if (installStatus?.required && !fallbackUser) { + clearAuthSession(); + navigate('/install', { replace: true }); + return; + } + + let resolvedUser = null; + const verifyError = verification.error; + if (verification.res?.valid) { + resolvedUser = verification.res.user; + } else if (verifyError) { + console.warn('Token verification failed:', verifyError); + } + + if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { + resolvedUser = fallbackUser; + } + + if (resolvedUser) { + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + if (resolvedUser.role === 'admin') { + try { + const setup = setupStatusPromise + ? await setupStatusPromise + : await getSetupStatus(); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + return; + } + } catch (error) { + console.warn('Setup status unavailable, continue with current session:', error); + } + } + + setUser(resolvedUser); + } else if (installStatus?.required) { + clearAuthSession(); + navigate('/install', { replace: true }); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-missing')) { + navigate('/login'); + } + } + } catch (error) { + console.error(error); + if (fallbackUser) { + setUser(fallbackUser); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-invalid')) { + navigate('/login'); + } + } + } finally { + setAuthReady(true); + } + }; + + checkAuth(); + }, [navigate]); + + useEffect(() => { + if (!authReady || !user) { + return; + } + + if (pathname === '/' || pathname === '/dashboard') { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + if (pathname.startsWith('/settings') && !canUseSettings) { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + const isAllowedPath = ( + pathname === '/planning' + || pathname.startsWith('/planning/') + || pathname === '/settings' + || pathname.startsWith('/settings/') + ); + + if (!isAllowedPath) { + navigate(PLANNING_HOME_URL, { replace: true }); + } + }, [authReady, canUseSettings, navigate, pathname, user]); + + const handleLogout = () => { + clearAuthSession(); + navigate('/login'); + }; + + const userMenu = [ + canUseSettings + ? { + key: 'settings', + icon: , + label: '系统设置', + onClick: () => navigate('/settings'), + } + : null, + { + key: 'logout', + icon: , + label: '退出登录', + onClick: handleLogout, + }, + ].filter(Boolean); + + const isSidebarEntryActive = (entry) => { + if (!entry) return false; + if (entry.match?.(pathname)) return true; + return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); + }; + + const toggleSidebarGroup = (key) => { + setSidebarCollapsed(false); + setExpandedSidebarKeys((keys) => ( + keys.includes(key) + ? keys.filter((item) => item !== key) + : [...keys, key] + )); + }; + + const handleSidebarLeafClick = (item) => { + if (item.external) { + item.onClick?.(); + return; + } + if (item.path) { + navigate(item.path); + } + }; + + const handleSidebarDarkModeChange = (checked) => { + setSidebarDarkMode(checked); + try { + localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); + } catch { + // Keep the current-session preference when persistence is unavailable. + } + }; + + if (!authReady) { + return ; + } + + return ( +
+ + +
+ + + +
+ + +
+ ); +}; + +export default BasicLayout; -- 2.47.2 From b57fc1ad5bb0bdc3f97c6afe43e055835a3cf5dd Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:01 +0800 Subject: [PATCH 20/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From eaa8489600609924fa612a61197bd4d5e07d3f3e Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:02 +0800 Subject: [PATCH 21/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From e4b6bbcf315e002e328cb51fbe2aacadd2f29015 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:03 +0800 Subject: [PATCH 22/22] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/router/index.jsx | 313 +++++++++++++++++----------------- 1 file changed, 159 insertions(+), 154 deletions(-) diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 0c64a0c..05a471b 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -1,154 +1,159 @@ -import React, { lazy, Suspense } from 'react'; -import { createBrowserRouter, Navigate } from 'react-router-dom'; -import BasicLayout from '../layouts/BasicLayout'; -import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton'; - -const Login = lazy(() => import('../pages/Login')); -const Install = lazy(() => import('../pages/Install')); -const Setup = lazy(() => import('../pages/Setup')); -const Settings = lazy(() => import('../pages/Settings')); -const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers')); -const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks')); -const MarketingEvents = lazy(() => import('../pages/MarketingEvents')); -const ProductInformation = lazy(() => import('../pages/ProductInformation')); -const PromotionModule = lazy(() => import('../pages/PromotionModule')); -const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder')); -const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview')); -const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis')); -const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard')); -const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); -const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); -const MkhBomData = lazy(() => import('../pages/MkhBomData')); - -const lazyLoadingElement = ; - -const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => { - const LazyView = lazyView; - - return ( - - - - ); -}; - -const HOME_URL = '/planning/dev-style-preview'; - -const router = createBrowserRouter([ - { - path: '/login', - element: renderLazyComponent(Login, {}, ), - }, - { - path: '/callback', - element: renderLazyComponent(Login, {}, ), - }, - { - path: '/setup', - element: renderLazyComponent(Setup), - }, - { - path: '/install', - element: renderLazyComponent(Install), - }, - { - path: '/', - element: , - children: [ - { - index: true, - element: , - }, - { - path: 'dashboard', - element: , - }, - { - path: 'channel', - element: , - }, - { - path: 'planning', - element: , - }, - { - path: 'planning/operation/marketing-events', - element: , - }, - { - path: 'planning/operation/marketing-events/activity-products', - element: renderLazyComponent(MarketingEvents), - }, - { - path: 'planning/operation/marketing-tools', - element: , - }, - { - path: 'planning/operation/product-information', - element: renderLazyComponent(ProductInformation), - }, - { - path: 'planning/operation/product-analysis', - element: renderLazyComponent(ProductAnalysis), - }, - { - path: 'planning/operation/promotion-module', - element: renderLazyComponent(PromotionModule), - }, - { - path: 'planning/operation/ops-b-incentive', - element: renderLazyComponent(OpsBIncentiveBoard), - }, - { - path: 'planning/operation/product-monitoring', - element: renderLazyComponent(ProductMonitoring), - }, - { - path: 'planning/operation/modular-stock-monitor', - element: renderLazyComponent(ModularStockMonitor), - }, - { - path: 'planning/operation/mkh-bom-data', - element: renderLazyComponent(MkhBomData), - }, - { path: 'planning/operation/store-checkup', element: }, - { path: 'planning/operation/review-analysis', element: }, - { path: 'planning/operation/customer-service-checkup', element: }, - { path: 'planning/operation/pricing-calculator', element: }, - { path: 'planning/operation/desktop-client-download', element: }, - { path: 'planning/operation/ai-buyer-show', element: }, - { path: 'planning/operation/ai-seeding-video', element: }, - { path: 'settings/desktop-client-releases', element: }, - { - path: 'planning/operation/:featureKey', - element: renderLazyComponent(OperationPlaceholder), - }, - { - path: 'planning/upload', - element: , - }, - { - path: 'planning/dev-style-preview', - element: renderLazyComponent(DevelopmentStylePreview), - }, - { - path: 'settings', - element: renderLazyComponent(Settings), - }, - { - path: 'settings/online-desktop-users', - element: renderLazyComponent(OnlineDesktopUsers), - }, - { - path: 'settings/desktop-execution-tasks', - element: renderLazyComponent(DesktopExecutionTasks), - }, - { - path: '*', - element: , - }, - ], - }, -]); - -export default router; +import React, { lazy, Suspense } from 'react'; +import { createBrowserRouter, Navigate } from 'react-router-dom'; +import BasicLayout from '../layouts/BasicLayout'; +import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton'; + +const Login = lazy(() => import('../pages/Login')); +const Install = lazy(() => import('../pages/Install')); +const Setup = lazy(() => import('../pages/Setup')); +const Settings = lazy(() => import('../pages/Settings')); +const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers')); +const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks')); +const MarketingEvents = lazy(() => import('../pages/MarketingEvents')); +const ProductInformation = lazy(() => import('../pages/ProductInformation')); +const PromotionModule = lazy(() => import('../pages/PromotionModule')); +const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder')); +const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview')); +const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis')); +const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard')); +const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); +const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); +const MkhBomData = lazy(() => import('../pages/MkhBomData')); +const DailyInventory = lazy(() => import('../pages/DailyInventory')); + +const lazyLoadingElement = ; + +const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => { + const LazyView = lazyView; + + return ( + + + + ); +}; + +const HOME_URL = '/planning/dev-style-preview'; + +const router = createBrowserRouter([ + { + path: '/login', + element: renderLazyComponent(Login, {}, ), + }, + { + path: '/callback', + element: renderLazyComponent(Login, {}, ), + }, + { + path: '/setup', + element: renderLazyComponent(Setup), + }, + { + path: '/install', + element: renderLazyComponent(Install), + }, + { + path: '/', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'dashboard', + element: , + }, + { + path: 'channel', + element: , + }, + { + path: 'planning', + element: , + }, + { + path: 'planning/operation/marketing-events', + element: , + }, + { + path: 'planning/operation/marketing-events/activity-products', + element: renderLazyComponent(MarketingEvents), + }, + { + path: 'planning/operation/marketing-tools', + element: , + }, + { + path: 'planning/operation/product-information', + element: renderLazyComponent(ProductInformation), + }, + { + path: 'planning/operation/product-analysis', + element: renderLazyComponent(ProductAnalysis), + }, + { + path: 'planning/operation/promotion-module', + element: renderLazyComponent(PromotionModule), + }, + { + path: 'planning/operation/ops-b-incentive', + element: renderLazyComponent(OpsBIncentiveBoard), + }, + { + path: 'planning/operation/product-monitoring', + element: renderLazyComponent(ProductMonitoring), + }, + { + path: 'planning/operation/modular-stock-monitor', + element: renderLazyComponent(ModularStockMonitor), + }, + { + path: 'planning/operation/mkh-bom-data', + element: renderLazyComponent(MkhBomData), + }, + { + path: 'planning/operation/daily-inventory', + element: renderLazyComponent(DailyInventory), + }, + { path: 'planning/operation/store-checkup', element: }, + { path: 'planning/operation/review-analysis', element: }, + { path: 'planning/operation/customer-service-checkup', element: }, + { path: 'planning/operation/pricing-calculator', element: }, + { path: 'planning/operation/desktop-client-download', element: }, + { path: 'planning/operation/ai-buyer-show', element: }, + { path: 'planning/operation/ai-seeding-video', element: }, + { path: 'settings/desktop-client-releases', element: }, + { + path: 'planning/operation/:featureKey', + element: renderLazyComponent(OperationPlaceholder), + }, + { + path: 'planning/upload', + element: , + }, + { + path: 'planning/dev-style-preview', + element: renderLazyComponent(DevelopmentStylePreview), + }, + { + path: 'settings', + element: renderLazyComponent(Settings), + }, + { + path: 'settings/online-desktop-users', + element: renderLazyComponent(OnlineDesktopUsers), + }, + { + path: 'settings/desktop-execution-tasks', + element: renderLazyComponent(DesktopExecutionTasks), + }, + { + path: '*', + element: , + }, + ], + }, +]); + +export default router; -- 2.47.2