32、智能仓库管理与优化系统 (模拟) - /物流与仓储组件/warehouse-optimization-system
76个工业组件库示例汇总
智能仓库管理与优化系统 (模拟)
概述
这是一个交互式的 Web 组件,用于模拟现代电商仓库的核心运作流程,包括库存可视化、订单处理、拣货路径优化和自动化分拣调度。用户可以生成模拟订单,观察系统如何计算(简化的)最优拣货路径,并观看拣货员和包裹在仓库中移动的动态模拟。
请注意:这是一个概念演示组件。仓库布局、库存数据、路径优化算法和分拣逻辑均为高度简化和模拟的,旨在展示核心概念而非提供精确的 WMS 功能。
主要功能
- 仓库可视化:
- 使用 Canvas 动态绘制仓库布局,包括货架、拣货站、分拣站、模拟的传送带系统和发货出口。
- 在布局图上高亮显示当前订单所需拣选商品所在的货架位置。
- 订单与库存模拟:
- 动态生成包含多个 SKU 的模拟客户订单,并随机分配一个出口。
- 在左侧面板显示当前订单详情。
- 展示简化的库存概览(SKU 及其模拟数量)。
- 拣货路径优化 (模拟):
- 根据当前订单,计算从起点出发,经过所有拣货点,最终到达拣货站的简化路径。
- 在 Canvas 上以虚线形式绘制计算出的路径。
- 模拟拣货员(红色圆点)沿计算出的路径移动的动画。
- 在右侧面板显示计算出的路径长度(米)和预估拣货时间(秒)。
- 自动化分拣 (模拟):
- 在拣货完成后,启动分拣模拟。
- 模拟包裹(灰色方块)从分拣站进入传送带系统。
- 简化地模拟包裹沿直线移动至其订单指定的目标出口。
- 在右侧面板显示模拟处理的订单数和简化的吞吐量估算。
- 界面与风格:
- 采用炫酷的苹果科技风格(深色背景、亮色强调、渐变按钮)。
- 三栏响应式布局(订单/库存 | 可视化 | 控制/结果)。
如何使用
- 打开页面: 在浏览器中打开
index.html
。仓库布局和初始库存概览会自动加载。 - 生成订单: 点击左侧面板底部的"生成新订单"按钮。一个新的模拟订单(包含 2-4 种商品)会显示在订单区域,同时仓库图中对应的货架会被高亮。
- 优化拣货路径: 点击右侧面板的"优化拣货路径"按钮。
- 系统会计算一条连接起点、所有高亮拣货点和拣货站的简化路径,并在图上用虚线绘制出来。
- 右侧面板会显示路径长度和预估时间。
- 一个代表拣货员的红色圆点会开始沿路径移动。
- 拣货状态会显示"拣货中…"。
- 启动分拣模拟: 当拣货路径计算完成(或拣货员移动完成)后,"启动分拣模拟"按钮会启用。点击该按钮:
- 代表包裹的灰色方块会出现在分拣站。
- 包裹会沿直线移动到其订单指定的目标出口。
- 分拣状态会显示"模拟中…"。
- 查看结果: 拣货和分拣模拟完成后,右侧面板的状态会更新为"完成",并可能显示一些模拟结果(如吞吐量)。
- 重置: 点击"重置视图"按钮可以清除当前订单、路径和动画,返回初始状态,准备生成新订单。
文件结构
/物流与仓储组件/warehouse-optimization-system/
├── index.html # 组件的 HTML 结构
├── styles.css # 组件的 CSS 样式 (深色科技风)
├── script.js # 组件的 JavaScript 逻辑 (模拟、交互、Canvas绘图)
└── README.md # 当前说明文件
技术栈
- HTML5
- CSS3 (Flexbox 布局, CSS Variables, Gradients)
- JavaScript (ES6+)
- DOM 操作
- Canvas 2D API (绘图与动画)
Map
数据结构requestAnimationFrame
for simulation loop- 模拟数据和简化算法
重要说明
- 简化模拟: 拣货路径优化未使用高级算法(如 A* 或 TSP),而是顺序连接目标点。包裹移动也简化为直线运动。库存和订单逻辑非常基础。
- 无外部依赖: 组件不依赖任何外部 JavaScript 库。
- 可扩展性: 代码中预留了 TODO 注释,标示了可以替换简化逻辑(如路径查找、传送带逻辑)的地方。
效果展示
源码
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>智能仓库管理与优化系统</title><link rel="stylesheet" href="styles.css">
</head>
<body><div class="warehouse-container"><aside class="control-panel left-panel"><h2>订单与库存</h2><section class="order-section"><h3>模拟订单</h3><div id="orderList"><!-- 模拟订单将由 JS 动态添加 --><p class="placeholder">暂无订单</p></div><button id="generateOrderBtn" class="action-button secondary-button">生成新订单</button></section><section class="inventory-overview"><h3>库存概览 (模拟)</h3><div id="inventorySummary"><p class="placeholder">加载库存...</p><!-- 库存摘要将由 JS 动态添加 --></div></section></aside><main class="visualization-panel"><h2>仓库布局与模拟</h2><div id="warehouseCanvasContainer" class="canvas-container"><canvas id="warehouseCanvas"></canvas><div id="simulationOverlay"></div> <!-- For potential overlays or messages --><div id="simStatusDisplay" class="sim-status-display" style="display: none;"></div></div></main><aside class="control-panel right-panel"><h2>模拟控制与结果</h2><section class="simulation-controls"><h3>操作</h3><button id="optimizePathBtn" class="action-button" disabled>优化拣货路径</button><button id="startSortingBtn" class="action-button" disabled>启动分拣模拟</button><button id="resetSimulationBtn" class="action-button secondary-button">重置视图</button></section><section class="results-display"><h3>拣货优化结果</h3><div id="pickingResult" class="result-group"><p><strong>状态:</strong> <span id="pickingStatus">未开始</span></p><p><strong>优化路径长度:</strong> <span id="pathLength">--</span> 米</p><p><strong>预计拣货时间:</strong> <span id="pickingTime">--</span> 秒</p></div></section><section class="results-display"><h3>分拣模拟结果</h3><div id="sortingResult" class="result-group"><p><strong>状态:</strong> <span id="sortingStatus">未开始</span></p><p><strong>处理订单数:</strong> <span id="ordersProcessed">0</span></p><p><strong>模拟吞吐量:</strong> <span id="throughput">--</span> 件/分钟</p></div></section></aside></div><script src="script.js"></script>
</body>
</html>
styles.css
:root {/* Light Mode Apple Tech Style */--bg-color: #f8f8fa; /* Light background */--panel-bg: #ffffff; /* White panel background */--canvas-bg: #e9e9ed; /* Light grey canvas background */--border-color: #d1d1d6; /* Lighter border */--text-primary: #1d1d1f; /* Dark text */--text-secondary: #6e6e73; /* Grey text */--text-placeholder: #a0a0a5;--accent-blue: #007aff;--accent-blue-hover: #005ecb;--accent-green: #34c759; /* Adjusted green */--accent-yellow: #ff9500; /* Adjusted yellow/orange */--accent-red: #ff3b30;--highlight-color: var(--accent-blue);--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--border-radius: 8px; /* Slightly reduced */--medium-border-radius: 6px;--small-border-radius: 4px;--panel-padding: 15px;--section-spacing: 15px;--box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* Softer shadow */--button-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);/* Canvas Element Colors (Light Theme) */--shelf-fill: #d1d1d6; /* Light grey shelves */--shelf-stroke: #b0b0b5;--aisle-color: var(--canvas-bg); /* Aisles same as canvas bg */--station-color: var(--accent-yellow);--conveyor-color: #a0a0a5; /* Grey conveyor */--exit-color: var(--accent-green);--picker-color: var(--accent-red);--path-color: var(--accent-blue);--package-color: #555; /* Darker package for contrast */
}body {margin: 0;padding: 0;font-family: var(--font-family);background-color: var(--bg-color);color: var(--text-primary);line-height: 1.6;-webkit-font-smoothing: antialiased;-moz-osx-font-smoothing: grayscale;height: 100vh;display: flex;justify-content: center;align-items: center;overflow: hidden;
}.warehouse-container {display: flex;width: 96%;max-width: 1600px;height: 90vh;max-height: 800px;background-color: var(--panel-bg);border: 1px solid var(--border-color);border-radius: var(--border-radius);box-shadow: var(--box-shadow);overflow: hidden;
}/* Layout Panels */
.control-panel {flex: 0 0 300px;padding: var(--panel-padding);display: flex;flex-direction: column;overflow-y: auto;background-color: #f2f2f7; /* Slightly off-white for side panels */border-color: var(--border-color);
}.left-panel {border-right: 1px solid var(--border-color);
}.right-panel {border-left: 1px solid var(--border-color);
}.visualization-panel {flex: 1 1 auto; /* Takes most space */display: flex;flex-direction: column;padding: var(--panel-padding);overflow: hidden;background-color: var(--panel-bg); /* Match main panel bg */border-color: var(--border-color);
}/* Typography & Common Elements */
h2 {font-size: 1.2rem;font-weight: 600;color: var(--text-primary);margin-top: 0;margin-bottom: 20px;border-bottom: 1px solid var(--border-color);padding-bottom: 10px;text-align: center;
}h3 {font-size: 1rem;font-weight: 500;color: var(--text-secondary);margin-top: 0;margin-bottom: 15px;/* text-transform: uppercase; */ /* Cleaner look */letter-spacing: 0.5px;
}section {margin-bottom: var(--section-spacing);background-color: transparent; /* Remove dark section background */padding: 0; /* Reset padding if removing background */border: none; /* Remove border if removing background */
}.placeholder {color: var(--text-placeholder);font-style: italic;font-size: 0.9rem;text-align: center;padding: 15px 0;
}.action-button {display: block;width: 100%;padding: 10px 15px;font-size: 0.95rem;font-weight: 600;color: #fff;background-image: linear-gradient(to bottom, var(--accent-blue), #006BEA); /* Adjusted gradient */border: none;border-radius: var(--medium-border-radius);cursor: pointer;transition: all 0.2s ease;text-align: center;margin-bottom: 10px;box-shadow: var(--button-shadow);
}.action-button:hover:not(:disabled) {background-image: linear-gradient(to bottom, var(--accent-blue-hover), var(--accent-blue));box-shadow: 0 2px 4px rgba(0, 122, 255, 0.2); /* Softer hover shadow */transform: translateY(-1px);
}.action-button:active:not(:disabled) {transform: translateY(0px);background-image: linear-gradient(to top, var(--accent-blue), #006BEA); /* Reverse gradient slightly on press */box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1);
}.action-button:disabled {background-image: none;background-color: #c7c7cc; /* Disabled color for light theme */cursor: not-allowed;opacity: 0.7;box-shadow: none;color: var(--text-placeholder); /* Lighter text when disabled */
}.action-button.secondary-button {background-image: none;background-color: #e5e5ea; /* Light grey secondary button */color: var(--accent-blue);box-shadow: var(--button-shadow);
}.action-button.secondary-button:hover:not(:disabled) {background-color: #dcdce0;box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}.action-button.secondary-button:active:not(:disabled) {background-color: #d1d1d6;box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1);
}/* Left Panel: Order & Inventory */
.order-section #orderList {max-height: 150px;overflow-y: auto;margin-bottom: 15px;padding-right: 5px; /* Space for scrollbar */
}.order-item {font-size: 0.9rem;padding: 8px 10px;margin-bottom: 5px;background-color: #f2f2f7; /* Lighter background for items */border-radius: var(--small-border-radius);border-left: 3px solid var(--accent-green);
}.order-item strong {color: var(--text-primary); /* Darker text for order ID */
}.inventory-overview #inventorySummary {font-size: 0.9rem;color: var(--text-secondary);max-height: 200px;overflow-y: auto;
}.inventory-item {display: flex;justify-content: space-between;padding: 4px 0;border-bottom: 1px solid var(--border-color);
}
.inventory-item:last-child {border-bottom: none;
}
.inventory-item .item-name {color: var(--text-primary);
}
.inventory-item .item-count {font-weight: 600;color: var(--text-secondary); /* Less prominent count color */
}/* Middle Panel: Visualization */
.visualization-panel h2 {border-bottom: none;margin-bottom: 15px;color: var(--text-secondary);
}.canvas-container {flex-grow: 1;position: relative;background-color: var(--canvas-bg);border-radius: var(--medium-border-radius);overflow: hidden;border: 1px solid var(--border-color);
}#warehouseCanvas {display: block;width: 100%;height: 100%;position: absolute; /* Needed for canvas */top: 0;left: 0;
}#simulationOverlay {position: absolute;top: 0;left: 0;width: 100%;height: 100%;pointer-events: none; /* Allow clicks through */z-index: 10;
}.sim-status-display {position: absolute;bottom: 10px;left: 10px;background-color: rgba(255, 255, 255, 0.8); /* Light status bg */color: var(--text-primary); /* Dark text */padding: 5px 10px;border-radius: var(--small-border-radius);font-size: 0.85rem;z-index: 11;border: 1px solid var(--border-color);
}/* --- Canvas Element Styles (managed by JS, but define base appearance) --- */
/* Defined in :root *//* Right Panel: Controls & Results */
.results-display .result-group {font-size: 0.9rem;
}.results-display p {margin: 5px 0;display: flex;justify-content: space-between;
}.results-display strong {color: var(--text-secondary);margin-right: 10px;
}.results-display span {font-weight: 600;color: var(--text-primary);text-align: right;
}#pickingStatus,
#sortingStatus {color: var(--accent-yellow);
}#pickingStatus.completed,
#sortingStatus.completed {color: var(--accent-green);
}
#pickingStatus.error,
#sortingStatus.error {color: var(--accent-red);
}/* Animations (Placeholder - actual animation done via JS/Canvas) */
@keyframes pulse {0% { box-shadow: 0 0 0 0 rgba(0, 122, 255, 0.4); } /* Adjusted pulse color/opacity */70% { box-shadow: 0 0 0 10px rgba(0, 122, 255, 0); }100% { box-shadow: 0 0 0 0 rgba(0, 122, 255, 0); }
}.highlighted-item { /* Example class JS might add *//* animation: pulse 1.5s infinite; */ /* Pulse might be too distracting */outline: 2px solid var(--highlight-color);outline-offset: 2px;
}/* Responsive Design */
@media (max-width: 1200px) {.warehouse-container {flex-direction: column;height: auto;max-height: none;width: 100%;border: none;border-radius: 0;}.control-panel {flex: 0 0 auto;width: 100%;border-right: none;border-left: none;border-bottom: 1px solid var(--border-color);max-height: 280px; /* Limit height on mobile */overflow-y: auto;background-color: var(--panel-bg); /* Match main bg on mobile */}.visualization-panel {min-height: 400px; /* Ensure canvas area is usable */order: -1; /* Move viz to top */border-bottom: 1px solid var(--border-color);background-color: var(--panel-bg);}.right-panel {border-bottom: none;max-height: none;}
}@media (max-width: 600px) {h2 { font-size: 1.1rem; margin-bottom: 15px; }h3 { font-size: 0.9rem; }.control-panel { padding: 15px; max-height: 250px; }.visualization-panel { padding: 10px; min-height: 300px; }.action-button { padding: 8px 12px; font-size: 0.9rem; }
}
script.js
// --- DOM Elements ---
const orderListElement = document.getElementById('orderList');
const generateOrderBtn = document.getElementById('generateOrderBtn');
const inventorySummaryElement = document.getElementById('inventorySummary');
const warehouseCanvas = document.getElementById('warehouseCanvas');
const canvasContainer = document.getElementById('warehouseCanvasContainer');
const simStatusDisplay = document.getElementById('simStatusDisplay');
const optimizePathBtn = document.getElementById('optimizePathBtn');
const startSortingBtn = document.getElementById('startSortingBtn');
const resetSimulationBtn = document.getElementById('resetSimulationBtn');
const pickingStatusElement = document.getElementById('pickingStatus');
const pathLengthElement = document.getElementById('pathLength');
const pickingTimeElement = document.getElementById('pickingTime');
const sortingStatusElement = document.getElementById('sortingStatus');
const ordersProcessedElement = document.getElementById('ordersProcessed');
const throughputElement = document.getElementById('throughput');// --- Canvas & Drawing --- TODO
const ctx = warehouseCanvas.getContext('2d');
let canvasWidth, canvasHeight;
let scaleFactor = 10; // Pixels per meter (adjust based on layout)
let warehouseLayout; // Stores grid, shelves, stations etc.
let inventoryData = new Map(); // Map SKU -> {location: {x, y, shelfId}, quantity: number}
let currentOrder = null;
let picker = { x: 0, y: 0, path: [], progress: 0, speed: 2 }; // Picker simulation object (meters/tick)
let packages = []; // For sorting simulation: {id, x, y, targetExit, path: [], progress: 0, speed: 3}
let animationFrameId = null;
let simulationState = 'idle'; // 'idle', 'picking_path_calculated', 'picking_active', 'sorting_active'// --- Simulation Config --- TODO
const TICK_INTERVAL_MS = 50; // Update frequency for animations
const PICKING_SPEED_MPS = 1.5; // Meters per second
const SORTING_SPEED_MPS = 0.8;
const START_POS = { x: 1, y: 1 }; // Picker start (near entrance/station)
const PICKING_STATION_POS = { x: 1, y: 5 }; // Where items are brought
const SORTING_STATION_POS = { x: 3, y: 18 }; // Where packages enter sorting// --- Mock Data --- TODO
const mockSKUs = [{ sku: 'SKU001', name: '智能手机 AX', locations: [{ shelfId: 'A1', quantity: 50 }] },{ sku: 'SKU002', name: '无线耳机 BZ', locations: [{ shelfId: 'A2', quantity: 120 }] },{ sku: 'SKU003', name: '笔记本电脑 C15', locations: [{ shelfId: 'B1', quantity: 30 }] },{ sku: 'SKU004', name: '智能手表 SW4', locations: [{ shelfId: 'C1', quantity: 75 }] },{ sku: 'SKU005', name: '充电宝 PB10k', locations: [{ shelfId: 'D2', quantity: 200 }] },{ sku: 'SKU006', name: '蓝牙音箱 SPK5', locations: [{ shelfId: 'B2', quantity: 60 }] },{ sku: 'SKU007', name: '游戏手柄 GPX', locations: [{ shelfId: 'C2', quantity: 45 }] },{ sku: 'SKU008', name: 'VR头显 VRH2', locations: [{ shelfId: 'D1', quantity: 25 }] },
];// --- Warehouse Layout Definition --- TODO
// Grid size, shelf positions/dimensions, aisle points, station positions, conveyor paths, exit points
function defineWarehouseLayout() {const gridWidth = 40; // metersconst gridHeight = 20; // meterswarehouseLayout = {width: gridWidth,height: gridHeight,shelves: [// Define shelf blocks {id, x, y, w, h}{ id: 'A1', x: 5, y: 2, w: 2, h: 4 }, { id: 'A2', x: 5, y: 7, w: 2, h: 4 },{ id: 'B1', x: 10, y: 2, w: 2, h: 4 }, { id: 'B2', x: 10, y: 7, w: 2, h: 4 },{ id: 'C1', x: 15, y: 2, w: 2, h: 4 }, { id: 'C2', x: 15, y: 7, w: 2, h: 4 },{ id: 'D1', x: 20, y: 2, w: 2, h: 4 }, { id: 'D2', x: 20, y: 7, w: 2, h: 4 },// Add more shelves...],stations: {picking: PICKING_STATION_POS,sorting: SORTING_STATION_POS,packing: { x: 35, y: 18 } // Example},conveyors: [// Define conveyor segments [{x1,y1, x2,y2}, ...]{ x1: SORTING_STATION_POS.x, y1: SORTING_STATION_POS.y, x2: 30, y2: SORTING_STATION_POS.y }, // Main belt{ x1: 30, y1: SORTING_STATION_POS.y, x2: 30, y2: 15 }, // Branch 1 up{ x1: 30, y1: SORTING_STATION_POS.y, x2: 30, y2: 21 }, // Branch 2 down (example){ x1: 30, y1: 15, x2: 35, y2: 15}, // To Exit 1{ x1: 30, y1: 21, x2: 35, y2: 21}, // To Exit 2 (example)],exits: [{ id: 'Exit1', x: 36, y: 15 },{ id: 'Exit2', x: 36, y: 21 }// Add more exits mapped to regions/carriers],walkableGrid: null // Will be generated based on shelves};// Generate walkable grid (simple A* needs this)generateWalkableGrid();// Assign locations to inventory based on shelf IDspopulateInventoryLocations();
}function populateInventoryLocations() {inventoryData.clear();mockSKUs.forEach(item => {item.locations.forEach(loc => {const shelf = warehouseLayout.shelves.find(s => s.id === loc.shelfId);if (shelf) {// For simplicity, use center of the shelf front as pick locationconst pickX = shelf.x; // Front edge of shelfconst pickY = shelf.y + shelf.h / 2; // Center verticallyinventoryData.set(item.sku, {name: item.name,location: { x: pickX, y: pickY, shelfId: loc.shelfId },quantity: loc.quantity});}});});displayInventorySummary();
}function displayInventorySummary() {inventorySummaryElement.innerHTML = '';if (inventoryData.size === 0) {inventorySummaryElement.innerHTML = '<p class="placeholder">无库存数据</p>';return;}inventoryData.forEach((data, sku) => {const itemDiv = document.createElement('div');itemDiv.className = 'inventory-item';itemDiv.innerHTML = `<span class="item-name">${data.name} (${sku})</span> <span class="item-count">${data.quantity}</span>`;inventorySummaryElement.appendChild(itemDiv);});}function generateWalkableGrid() {const grid = [];const scaledWidth = Math.ceil(warehouseLayout.width);const scaledHeight = Math.ceil(warehouseLayout.height);for (let y = 0; y < scaledHeight; y++) {grid[y] = [];for (let x = 0; x < scaledWidth; x++) {grid[y][x] = 0; // 0: Walkable}}// Mark shelf areas as non-walkable (1)warehouseLayout.shelves.forEach(shelf => {const startX = Math.floor(shelf.x);const endX = Math.ceil(shelf.x + shelf.w);const startY = Math.floor(shelf.y);const endY = Math.ceil(shelf.y + shelf.h);for (let y = startY; y < endY; y++) {for (let x = startX; x < endX; x++) {if (grid[y] && grid[y][x] !== undefined) {grid[y][x] = 1; // 1: Obstacle}}}});warehouseLayout.walkableGrid = grid;
}// --- Drawing Functions --- TODO
function resizeCanvas() {const dpr = window.devicePixelRatio || 1;const rect = canvasContainer.getBoundingClientRect();canvasWidth = rect.width;canvasHeight = rect.height;warehouseCanvas.width = canvasWidth * dpr;warehouseCanvas.height = canvasHeight * dpr;warehouseCanvas.style.width = `${canvasWidth}px`;warehouseCanvas.style.height = `${canvasHeight}px`;ctx.scale(dpr, dpr);// Determine scaleFactor based on fitting layout into canvasconst scaleX = canvasWidth / warehouseLayout.width;const scaleY = canvasHeight / warehouseLayout.height;scaleFactor = Math.min(scaleX, scaleY) * 0.9; // Use 90% of min scale to add paddingdrawWarehouse(); // Redraw after resize
}function drawWarehouse() {if (!warehouseLayout) return;ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--canvas-bg').trim();ctx.fillRect(0, 0, canvasWidth, canvasHeight);// Draw Shelvesconst shelfFill = getComputedStyle(document.documentElement).getPropertyValue('--shelf-fill').trim();const shelfStroke = getComputedStyle(document.documentElement).getPropertyValue('--shelf-stroke').trim();ctx.fillStyle = shelfFill;ctx.strokeStyle = shelfStroke;ctx.lineWidth = 1;warehouseLayout.shelves.forEach(shelf => {ctx.fillRect(shelf.x * scaleFactor, shelf.y * scaleFactor, shelf.w * scaleFactor, shelf.h * scaleFactor);ctx.strokeRect(shelf.x * scaleFactor, shelf.y * scaleFactor, shelf.w * scaleFactor, shelf.h * scaleFactor);});// Draw Stationsconst stationColor = getComputedStyle(document.documentElement).getPropertyValue('--station-color').trim();ctx.fillStyle = stationColor;const stationRadius = 0.5 * scaleFactor;Object.values(warehouseLayout.stations).forEach(pos => {ctx.beginPath();ctx.arc(pos.x * scaleFactor, pos.y * scaleFactor, stationRadius, 0, Math.PI * 2);ctx.fill();// Optional: Add labelsctx.fillStyle = '#fff'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';const stationName = Object.keys(warehouseLayout.stations).find(key => warehouseLayout.stations[key] === pos);ctx.fillText(stationName.toUpperCase(), pos.x * scaleFactor, pos.y * scaleFactor + 15);});// Draw Conveyorsconst conveyorColor = getComputedStyle(document.documentElement).getPropertyValue('--conveyor-color').trim();ctx.strokeStyle = conveyorColor;ctx.lineWidth = 3;warehouseLayout.conveyors.forEach(seg => {ctx.beginPath();ctx.moveTo(seg.x1 * scaleFactor, seg.y1 * scaleFactor);ctx.lineTo(seg.x2 * scaleFactor, seg.y2 * scaleFactor);ctx.stroke();});// Draw Exitsconst exitColor = getComputedStyle(document.documentElement).getPropertyValue('--exit-color').trim();ctx.fillStyle = exitColor;ctx.font = 'bold 12px sans-serif';ctx.textAlign = 'center';warehouseLayout.exits.forEach(exit => {ctx.fillRect((exit.x - 0.5) * scaleFactor, (exit.y - 0.5) * scaleFactor, 1 * scaleFactor, 1 * scaleFactor);ctx.fillStyle = '#000';ctx.fillText(exit.id, exit.x * scaleFactor, exit.y * scaleFactor + 4);});// Draw order item locations (if order exists)if (currentOrder) {drawOrderHighlights();}// Draw optimized path (if calculated)if (simulationState === 'picking_path_calculated' && picker.path.length > 0) {drawPath(picker.path);}// Draw picker (if active)if (simulationState === 'picking_active') {drawPicker();}// Draw packages (if active)if (simulationState === 'sorting_active') {drawPackages();}
}function drawOrderHighlights() {if (!currentOrder) return;ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--highlight-color').trim() + '80'; // Semi-transparentctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--highlight-color').trim();ctx.lineWidth = 2;currentOrder.items.forEach(item => {const invData = inventoryData.get(item.sku);if (invData && invData.location) {const loc = invData.location;const shelf = warehouseLayout.shelves.find(s => s.id === loc.shelfId);if (shelf) {// Highlight the shelfctx.strokeRect(shelf.x * scaleFactor, shelf.y * scaleFactor, shelf.w * scaleFactor, shelf.h * scaleFactor);// Optional: Circle the pick pointctx.beginPath();ctx.arc(loc.x * scaleFactor, loc.y * scaleFactor, 0.4 * scaleFactor, 0, Math.PI * 2);ctx.fill();}}});}function drawPath(path) {if (!path || path.length < 2) return;const pathColor = getComputedStyle(document.documentElement).getPropertyValue('--path-color').trim();ctx.strokeStyle = pathColor;ctx.lineWidth = 3;ctx.lineDashOffset = 0;ctx.setLineDash([5, 5]); // Dashed line for pathctx.beginPath();ctx.moveTo(path[0].x * scaleFactor, path[0].y * scaleFactor);for (let i = 1; i < path.length; i++) {ctx.lineTo(path[i].x * scaleFactor, path[i].y * scaleFactor);}ctx.stroke();ctx.setLineDash([]); // Reset line dash}function drawPicker() {const pickerColor = getComputedStyle(document.documentElement).getPropertyValue('--picker-color').trim();ctx.fillStyle = pickerColor;ctx.beginPath();ctx.arc(picker.x * scaleFactor, picker.y * scaleFactor, 0.4 * scaleFactor, 0, Math.PI * 2);ctx.fill();// Add a small triangle for direction (optional)}function drawPackages() {const packageColor = getComputedStyle(document.documentElement).getPropertyValue('--package-color').trim();ctx.fillStyle = packageColor;packages.forEach(pkg => {ctx.fillRect((pkg.x - 0.2) * scaleFactor, (pkg.y - 0.2) * scaleFactor, 0.4 * scaleFactor, 0.4 * scaleFactor);});}// --- Simulation Logic --- TODOfunction generateOrder() {stopSimulation(); // Stop previous simulation if anyconst numItems = Math.floor(Math.random() * 3) + 2; // 2-4 items per orderconst availableSkus = Array.from(inventoryData.keys());if (availableSkus.length === 0) {alert("库存为空,无法生成订单!");return;}const items = [];const orderId = `ORD-${Date.now().toString().slice(-5)}`;for (let i = 0; i < numItems; i++) {if (availableSkus.length === items.length) break; // Avoid infinite loop if not enough unique SKUslet sku;do {sku = availableSkus[Math.floor(Math.random() * availableSkus.length)];} while (items.some(item => item.sku === sku)); // Ensure unique SKUs per orderconst quantity = 1; // For simplicity, always pick 1items.push({ sku, quantity });}currentOrder = { id: orderId, items, exitId: `Exit${Math.ceil(Math.random() * warehouseLayout.exits.length)}` }; // Assign random exitdisplayOrder();resetResults();simulationState = 'idle';optimizePathBtn.disabled = false;startSortingBtn.disabled = true;drawWarehouse(); // Redraw with highlights
}function displayOrder() {orderListElement.innerHTML = '';if (!currentOrder) {orderListElement.innerHTML = '<p class="placeholder">暂无订单</p>';return;}const orderDiv = document.createElement('div');orderDiv.className = 'order-item';let itemsHtml = currentOrder.items.map(item => `<li>${inventoryData.get(item.sku)?.name || item.sku} (x${item.quantity})</li>`).join('');orderDiv.innerHTML = `<strong>订单 ${currentOrder.id} (-> ${currentOrder.exitId})</strong><ul>${itemsHtml}</ul>`;orderListElement.appendChild(orderDiv);
}function optimizePickingPath() {if (!currentOrder || simulationState !== 'idle') return;pickingStatusElement.textContent = '计算中...';optimizePathBtn.disabled = true;// 1. Get target locations (SKUs from order -> inventory locations)const targetPoints = [START_POS]; // Start at the beginningcurrentOrder.items.forEach(item => {const invData = inventoryData.get(item.sku);if (invData && invData.location) {// Find the closest walkable point near the pick locationconst nearestWalkable = findNearestWalkable(invData.location.x, invData.location.y);if(nearestWalkable) targetPoints.push(nearestWalkable);}});targetPoints.push(PICKING_STATION_POS); // End at picking station// 2. Find optimized path (Simplified TSP using nearest neighbor or basic A* chaining)// For simplicity: Use A* to connect points sequentially (not true TSP optimal)// TODO: Implement a proper TSP solver if neededlet totalPath = [START_POS];let totalLength = 0;let pathCalculationSuccess = true;// --- Use A* --- TODO: Implement A* separately// const astar = new Astar(warehouseLayout.walkableGrid);// try {// for (let i = 0; i < targetPoints.length - 1; i++) {// const segment = astar.findPath(targetPoints[i], targetPoints[i + 1]);// if (!segment || segment.length === 0) throw new Error(`无法找到路径: ${i} -> ${i+1}`);// totalPath = totalPath.concat(segment.slice(1)); // Avoid duplicating points// totalLength += calculatePathSegmentLength(segment);// }// } catch (error) {// console.error("路径计算失败:", error);// pickingStatusElement.textContent = '路径错误';// pickingStatusElement.classList.add('error');// optimizePathBtn.disabled = false; // Allow retry?// pathCalculationSuccess = false;// }// --- End A* --- // --- Simplified Straight Line Path (for demo without A*) ---console.warn("Using simplified straight-line path for demo. Implement A* for proper routing.");for (let i = 0; i < targetPoints.length - 1; i++) {const p1 = targetPoints[i];const p2 = targetPoints[i+1];// Add intermediate points for smoother visual path (optional)const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));const steps = Math.ceil(dist / 0.5); // Add a point every 0.5m approxfor(let j=1; j <= steps; j++){const t = j / steps;totalPath.push({x: p1.x + (p2.x - p1.x) * t, y: p1.y + (p2.y - p1.y) * t});}totalLength += dist;}// --- End Simplified --- if (pathCalculationSuccess) {picker.path = totalPath;picker.progress = 0;picker.x = START_POS.x;picker.y = START_POS.y;const estimatedTime = totalLength / PICKING_SPEED_MPS;pickingStatusElement.textContent = '路径已优化';pickingStatusElement.className = '';pathLengthElement.textContent = totalLength.toFixed(1);pickingTimeElement.textContent = estimatedTime.toFixed(1);simulationState = 'picking_path_calculated';startSortingBtn.disabled = false; // Can now start sorting (or picking first)// TODO: Separate picking and sorting? Or just animate picker then sort?// For now, let's just make the picker movesimulationState = 'picking_active'; // Directly start animationstartSimulationLoop();drawWarehouse(); // Redraw with path}
}// TODO: Implement findNearestWalkable based on grid
function findNearestWalkable(targetX, targetY) {// Basic search around the target pointconst grid = warehouseLayout.walkableGrid;const maxRadius = 5;for (let r = 0; r < maxRadius; r++) {for (let dx = -r; dx <= r; dx++) {for (let dy = -r; dy <= r; dy++) {if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; // Only check perimeterconst x = Math.round(targetX) + dx;const y = Math.round(targetY) + dy;if (grid[y] && grid[y][x] === 0) {return {x, y};}}}}console.warn(`Could not find walkable point near ${targetX}, ${targetY}`);return {x: Math.round(targetX), y: Math.round(targetY)}; // Fallback}// TODO: Implement path length calculation for grid paths
function calculatePathSegmentLength(segment) { return 0; }function startSortingSimulation() {if (simulationState !== 'picking_path_calculated' && simulationState !== 'picking_active') return; // Allow starting sort after path calc or during pickingif (!currentOrder) return;stopSimulation(); // Stop picker animation if runningsortingStatusElement.textContent = '模拟中...';sortingStatusElement.className = '';startSortingBtn.disabled = true;optimizePathBtn.disabled = true;// Create package(s) at the sorting stationpackages = [];currentOrder.items.forEach((item, index) => {packages.push({id: `${currentOrder.id}-${index}`,x: SORTING_STATION_POS.x,y: SORTING_STATION_POS.y,targetExit: currentOrder.exitId,path: [], // Path on conveyor will be determined on the flyprogress: 0,currentNode: null, // For pathfinding on conveyorspeed: SORTING_SPEED_MPS * (TICK_INTERVAL_MS / 1000)});});ordersProcessedElement.textContent = '1'; // Simulate processing one ordersimulationState = 'sorting_active';startSimulationLoop();
}function resetSimulation() {stopSimulation();currentOrder = null;simulationState = 'idle';picker = { x: START_POS.x, y: START_POS.y, path: [], progress: 0, speed: PICKING_SPEED_MPS * (TICK_INTERVAL_MS / 1000) };packages = [];displayOrder();resetResults();optimizePathBtn.disabled = true;startSortingBtn.disabled = true;simStatusDisplay.style.display = 'none';drawWarehouse();
}function resetResults() {pickingStatusElement.textContent = '未开始';pickingStatusElement.className = '';pathLengthElement.textContent = '--';pickingTimeElement.textContent = '--';sortingStatusElement.textContent = '未开始';sortingStatusElement.className = '';ordersProcessedElement.textContent = '0';throughputElement.textContent = '--';
}// --- Animation Loop --- TODO
function startSimulationLoop() {if (animationFrameId) cancelAnimationFrame(animationFrameId);function gameLoop() {updateSimulationState();drawWarehouse(); // Redraw everythinganimationFrameId = requestAnimationFrame(gameLoop);}animationFrameId = requestAnimationFrame(gameLoop);simStatusDisplay.style.display = 'block';
}function stopSimulation() {if (animationFrameId) {cancelAnimationFrame(animationFrameId);animationFrameId = null;}// Keep results, just stop animationif (simulationState === 'picking_active') {simStatusDisplay.textContent = '拣货暂停';} else if (simulationState === 'sorting_active') {simStatusDisplay.textContent = '分拣暂停';}
}function updateSimulationState() {if (simulationState === 'picking_active') {simStatusDisplay.textContent = '拣货中...';movePicker();if (picker.progress >= picker.path.length -1) {simulationState = 'picking_path_calculated'; // Or 'picking_complete'?pickingStatusElement.textContent = '拣货完成';pickingStatusElement.className = 'completed';simStatusDisplay.style.display = 'none';// Enable sorting button explicitly if neededif(currentOrder) startSortingBtn.disabled = false;stopSimulation(); // Stop loop once done}} else if (simulationState === 'sorting_active') {simStatusDisplay.textContent = '自动分拣中...';movePackages();// Check if all packages reached exitsif (packages.every(p => p.reachedExit)) {simulationState = 'idle'; // Or 'sorting_complete'?sortingStatusElement.textContent = '分拣完成';sortingStatusElement.className = 'completed';// Calculate simple throughputconst timeTakenSec = packages.length * 2; // Very rough estimateconst tput = packages.length / (timeTakenSec / 60);throughputElement.textContent = tput.toFixed(1);simStatusDisplay.style.display = 'none';stopSimulation();}}
}function movePicker() {if (!picker.path || picker.path.length === 0 || picker.progress >= picker.path.length -1) return;const targetIndex = Math.min(picker.path.length - 1, Math.floor(picker.progress) + 1);const startPoint = picker.path[Math.floor(picker.progress)];const targetPoint = picker.path[targetIndex];const dx = targetPoint.x - startPoint.x;const dy = targetPoint.y - startPoint.y;const dist = Math.sqrt(dx*dx + dy*dy);if (dist < 0.1) { // Reached intermediate target pointpicker.progress = targetIndex;picker.x = targetPoint.x;picker.y = targetPoint.y;return;}const moveDist = picker.speed;const moveRatio = moveDist / dist;if (moveRatio >= 1) { // Can reach target in this steppicker.x = targetPoint.x;picker.y = targetPoint.y;picker.progress = targetIndex;} else {picker.x += dx * moveRatio;picker.y += dy * moveRatio;// Update progress proportionally based on distance covered in this segment// This assumes path points are roughly equidistant, simplify for now:picker.progress += moveRatio * (targetIndex - Math.floor(picker.progress));}}function movePackages() {packages.forEach(pkg => {if (pkg.reachedExit) return;// --- Basic Conveyor Logic (Needs Refinement) ---// Find the conveyor segment the package is on or heading towards// For now, just move towards the target exit in a straight line (visual cheat)const targetExitPos = warehouseLayout.exits.find(e => e.id === pkg.targetExit);if (!targetExitPos) { pkg.reachedExit = true; return; } // Cannot find exitconst dx = targetExitPos.x - pkg.x;const dy = targetExitPos.y - pkg.y;const dist = Math.sqrt(dx*dx + dy*dy);if (dist < 0.3) { // Reached exit vicinitypkg.reachedExit = true;return;}const moveDist = pkg.speed;const moveRatio = Math.min(1, moveDist / dist);pkg.x += dx * moveRatio;pkg.y += dy * moveRatio;// --- End Basic Logic ---// TODO: Implement path following on conveyors// 1. Find current segment// 2. Move along segment// 3. At junctions, decide based on targetExit});}// --- Initialization --- TODO
defineWarehouseLayout();
resizeCanvas(); // Initial draw
resetSimulation();// --- Event Listeners --- TODO
generateOrderBtn.addEventListener('click', generateOrder);
optimizePathBtn.addEventListener('click', optimizePickingPath);
startSortingBtn.addEventListener('click', startSortingSimulation);
resetSimulationBtn.addEventListener('click', resetSimulation);
window.addEventListener('resize', resizeCanvas);