下面是 Konva 第 4 天 的完整代码。我们在第 3 天可拖拽、可流动的基础上,实现了:
- 设备状态文本(跟随设备移动)
- 设备故障时自动变红并闪烁
- 立库货位区域,实时显示占用/空闲状态
- 用
setInterval模拟后端数据推送,随机改变设备与货位状态
第 4 天目标
- 根据状态动态改变设备颜色(正常 / 故障)
- 故障设备周期性闪烁(透明度变化)
- 每个设备上方显示状态文字(正常 / 故障 / 维护)
- 增加立库货位网格,根据数据切换占用/空闲颜色
- 所有状态更新由模拟的实时数据驱动
完整可运行代码(Day 4)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WCS 设备布局 - Day4 状态驱动与报警</title>
<style>
body {
margin: 0;
padding: 20px;
background: #f0f2f5;
font-family: sans-serif;
}
#container {
border: 1px solid #ccc;
background: #fff;
width: 800px;
height: 700px; /* 稍微加高,放置立库 */
cursor: default;
}
.info {
margin-top: 10px;
font-size: 14px;
color: #666;
}
</style>
</head>
<body>
<h2>仓库设备布局 - 状态驱动与报警</h2>
<div id="container"></div>
<div class="info">模拟后端数据每 2 秒更新一次。故障设备会变红闪烁,立库货位颜色代表占用状态。</div>
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
<script>
// ========== 1. 设备数据 ==========
const layoutData = {
layout: [
{
id: "1782803001807",
deviceCode: "stacker",
imgName: "ddj",
left: 480,
top: 275,
width: 50,
height: 40,
angle: 0,
moveLength: 200,
selected: false,
status: "normal" // normal / fault / maintenance
},
{
id: "1782803143726",
deviceCode: "conveyor",
imgName: "ssx2",
left: 640,
top: 275,
width: 50,
height: 40,
angle: 0,
moveLength: null,
selected: false,
status: "normal"
}
]
};
// ========== 2. 立库货位数据 ==========
// 模拟一个 4 行 × 3 列的立库
const storageRows = 4;
const storageCols = 3;
const storageData = [];
for (let r = 0; r < storageRows; r++) {
for (let c = 0; c < storageCols; c++) {
storageData.push({
id: `storage_${r}_${c}`,
row: r,
col: c,
occupied: Math.random() > 0.5 // 初始随机占用状态
});
}
}
// ========== 3. 画布初始化 ==========
const stage = new Konva.Stage({
container: 'container',
width: 800,
height: 700
});
const layer = new Konva.Layer();
stage.add(layer);
// 全局引用
let selectionRect = null;
let selectedNode = null;
const nodeStartPos = new Map();
let conveyorLine = null;
let cargoDot = null;
let animation = null;
let flowDirection = 1;
let flowProgress = 0;
// 存储状态文本和闪烁动画的映射
const statusTexts = new Map(); // key: device id, value: Konva.Text
const blinkTimers = new Map(); // key: device id, value: interval timer
// ========== 4. 创建选中框 ==========
function createSelectionRect() {
selectionRect = new Konva.Rect({
stroke: '#1e90ff',
strokeWidth: 2,
dash: [4, 4],
fill: 'rgba(30, 144, 255, 0.1)',
visible: false,
listening: false
});
layer.add(selectionRect);
}
createSelectionRect();
function updateSelectionRect(node) {
if (!node) {
selectionRect.visible(false);
layer.batchDraw();
return;
}
const box = node.getClientRect({ skipTransform: false });
selectionRect.position({ x: box.x, y: box.y });
selectionRect.size({ width: box.width, height: box.height });
selectionRect.visible(true);
layer.batchDraw();
}
function selectNode(node) {
if (selectedNode === node) return;
if (selectedNode) selectedNode.setAttr('selected', false);
selectedNode = node;
if (node) {
node.setAttr('selected', true);
updateSelectionRect(node);
} else {
updateSelectionRect(null);
}
}
stage.on('click', (e) => {
if (e.target === stage) selectNode(null);
});
// ========== 5. 拖拽处理 ==========
function onDragStart(e) {
const node = e.target;
nodeStartPos.set(node.id(), { x: node.x(), y: node.y() });
}
function onDragMove(e) {
const node = e.target;
const moveLength = node.getAttr('moveLength');
if (!moveLength) return;
const startPos = nodeStartPos.get(node.id());
if (!startPos) return;
const dx = node.x() - startPos.x;
const dy = node.y() - startPos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > moveLength) {
const ratio = moveLength / dist;
node.position({
x: startPos.x + dx * ratio,
y: startPos.y + dy * ratio
});
}
}
function onDragEnd(e) {
const node = e.target;
nodeStartPos.delete(node.id());
if (selectedNode === node) updateSelectionRect(node);
updateConveyorLine();
updateStatusTextPosition(node);
}
function bindEvents(node) {
node.on('click', (e) => {
e.evt.stopPropagation();
selectNode(node);
});
node.on('dragstart', onDragStart);
node.on('dragmove', onDragMove);
node.on('dragend', onDragEnd);
}
// ========== 6. 创建节点(含状态文本) ==========
function createDeviceNode(device) {
return new Promise((resolve) => {
const img = new window.Image();
img.onload = () => {
const node = new Konva.Image({
id: device.id,
image: img,
x: device.left,
y: device.top,
width: device.width,
height: device.height,
rotation: device.angle,
draggable: true,
deviceCode: device.deviceCode,
moveLength: device.moveLength,
selected: false,
status: device.status
});
resolve(node);
};
img.onerror = () => {
const node = new Konva.Rect({
id: device.id,
x: device.left,
y: device.top,
width: device.width,
height: device.height,
fill: '#cccccc',
stroke: '#333',
strokeWidth: 1,
rotation: device.angle,
draggable: true,
deviceCode: device.deviceCode,
moveLength: device.moveLength,
selected: false,
status: device.status
});
resolve(node);
};
img.src = `images/${device.imgName}.png`;
});
}
// 为设备创建状态文本
function createStatusText(node) {
const text = new Konva.Text({
text: node.getAttr('status') === 'normal' ? '正常' : '故障',
fontSize: 12,
fill: node.getAttr('status') === 'normal' ? '#27ae60' : '#e74c3c',
fontStyle: 'bold',
listening: false
});
// 将文本放置在节点上方中间
const box = node.getClientRect({ skipTransform: false });
text.position({
x: box.x + box.width / 2 - text.width() / 2,
y: box.y - 18
});
layer.add(text);
statusTexts.set(node.id(), text);
return text;
}
function updateStatusTextPosition(node) {
const text = statusTexts.get(node.id());
if (!text) return;
const box = node.getClientRect({ skipTransform: false });
text.position({
x: box.x + box.width / 2 - text.width() / 2,
y: box.y - 18
});
layer.batchDraw();
}
// ========== 7. 状态驱动更新函数 ==========
function updateDeviceStatus(id, newStatus) {
const node = stage.findOne('#' + id);
if (!node) return;
const oldStatus = node.getAttr('status');
if (oldStatus === newStatus) return;
node.setAttr('status', newStatus);
const text = statusTexts.get(id);
// 清除之前的闪烁动画
if (blinkTimers.has(id)) {
clearInterval(blinkTimers.get(id));
blinkTimers.delete(id);
// 恢复透明度
node.opacity(1);
}
if (newStatus === 'fault') {
// 设备变红
if (node.getClassName() === 'Rect') {
node.fill('#e74c3c');
}
// 更新文本
if (text) {
text.text('故障');
text.fill('#e74c3c');
updateStatusTextPosition(node);
}
// 启动闪烁
let opacityFlag = true;
const timer = setInterval(() => {
node.opacity(opacityFlag ? 0.3 : 1);
opacityFlag = !opacityFlag;
layer.batchDraw();
}, 400);
blinkTimers.set(id, timer);
} else if (newStatus === 'normal') {
if (node.getClassName() === 'Rect') {
node.fill('#2ecc71');
}
if (text) {
text.text('正常');
text.fill('#27ae60');
updateStatusTextPosition(node);
}
} else if (newStatus === 'maintenance') {
if (node.getClassName() === 'Rect') {
node.fill('#f39c12');
}
if (text) {
text.text('维护');
text.fill('#f39c12');
updateStatusTextPosition(node);
}
}
layer.batchDraw();
}
// ========== 8. 立库货位渲染 ==========
const storageRects = [];
const storageTexts = [];
function createStorageCells() {
const startX = 50;
const startY = 450;
const cellWidth = 60;
const cellHeight = 40;
const gap = 4;
storageData.forEach((cell) => {
const x = startX + cell.col * (cellWidth + gap);
const y = startY + cell.row * (cellHeight + gap);
const rect = new Konva.Rect({
id: cell.id,
x: x,
y: y,
width: cellWidth,
height: cellHeight,
fill: cell.occupied ? '#e74c3c' : '#2ecc71',
stroke: '#555',
strokeWidth: 1,
listening: false
});
layer.add(rect);
storageRects.push(rect);
const text = new Konva.Text({
x: x + 5,
y: y + cellHeight / 2 - 6,
text: cell.occupied ? '占用' : '空闲',
fontSize: 12,
fill: '#fff',
listening: false
});
layer.add(text);
storageTexts.push({ text, cellId: cell.id });
});
}
function updateStorageCell(cellId, occupied) {
const rect = layer.findOne('#' + cellId);
if (!rect) return;
rect.fill(occupied ? '#e74c3c' : '#2ecc71');
const item = storageTexts.find(t => t.cellId === cellId);
if (item) {
item.text.text(occupied ? '占用' : '空闲');
item.text.fill('#fff');
}
}
// ========== 9. 模拟实时数据推送 ==========
function randomStatusUpdate() {
// 随机更新设备状态
layoutData.layout.forEach(device => {
// 10% 概率改变状态
if (Math.random() < 0.1) {
const statuses = ['normal', 'fault', 'maintenance'];
const newStatus = statuses[Math.floor(Math.random() * 3)];
updateDeviceStatus(device.id, newStatus);
}
});
// 随机更新立库货位(30% 概率切换占用状态)
storageData.forEach(cell => {
if (Math.random() < 0.3) {
cell.occupied = !cell.occupied;
updateStorageCell(cell.id, cell.occupied);
}
});
layer.batchDraw();
}
// 启动模拟,每 2 秒执行一次
setInterval(randomStatusUpdate, 2000);
// ========== 10. 输送线与动画(同 Day3) ==========
function getDeviceConnectPoints() {
const stacker = stage.findOne('#1782803001807');
const conveyor = stage.findOne('#1782803143726');
if (!stacker || !conveyor) return null;
const stackerBox = stacker.getClientRect({ skipTransform: false });
const conveyorBox = conveyor.getClientRect({ skipTransform: false });
return {
startX: stackerBox.x + stackerBox.width,
startY: stackerBox.y + stackerBox.height / 2,
endX: conveyorBox.x,
endY: conveyorBox.y + conveyorBox.height / 2
};
}
function createConveyorLine() {
conveyorLine = new Konva.Line({
stroke: '#2ecc71',
strokeWidth: 4,
lineCap: 'round',
lineJoin: 'round',
points: [0, 0, 0, 0],
listening: false
});
layer.add(conveyorLine);
cargoDot = new Konva.Circle({
radius: 6,
fill: '#e67e22',
stroke: '#fff',
strokeWidth: 2,
x: 0,
y: 0,
listening: false
});
layer.add(cargoDot);
}
function updateConveyorLine() {
if (!conveyorLine) return;
const points = getDeviceConnectPoints();
if (!points) return;
conveyorLine.points([points.startX, points.startY, points.endX, points.endY]);
layer.batchDraw();
}
function startFlowAnimation() {
if (animation) return;
animation = new Konva.Animation(() => {
if (!cargoDot || !conveyorLine) return;
const points = getDeviceConnectPoints();
if (!points) return;
const speed = 0.008;
flowProgress += speed * flowDirection;
if (flowProgress >= 1) {
flowProgress = 1;
flowDirection = -1;
} else if (flowProgress <= 0) {
flowProgress = 0;
flowDirection = 1;
}
const cx = points.startX + (points.endX - points.startX) * flowProgress;
const cy = points.startY + (points.endY - points.startY) * flowProgress;
cargoDot.position({ x: cx, y: cy });
layer.batchDraw();
});
animation.start();
}
// ========== 11. 主渲染流程 ==========
async function renderLayout() {
const nodes = await Promise.all(
layoutData.layout.map(device => createDeviceNode(device))
);
nodes.forEach(node => {
bindEvents(node);
layer.add(node);
createStatusText(node); // 创建状态文字
});
// 创建立库区域
createStorageCells();
// 创建输送线
createConveyorLine();
updateConveyorLine();
startFlowAnimation();
layer.batchDraw();
console.log('Day4 就绪:实时状态驱动,故障报警闪烁,立库货位动态更新。');
}
renderLayout();
</script>
</body>
</html>
新增功能详解
1. 设备状态文字
- 每个设备上方显示一个
Konva.Text,初始值为"正常"。 - 文本位置根据设备包围盒自动计算,并在设备拖拽结束(
dragend)时更新位置。 - 当状态改变时,文字内容、颜色同步变化。
2. 故障报警与闪烁
updateDeviceStatus(id, newStatus)函数负责:- 更改设备节点的自定义属性
status - 清除之前可能存在的闪烁定时器
- 如果是
fault状态,将设备颜色设为红色,并启动一个setInterval循环改变opacity实现闪烁 - 恢复正常状态时清除定时器,恢复透明度和颜色
- 更改设备节点的自定义属性
3. 立库货位
- 新增一个 4 行 × 3 列的立库区域,用
Konva.Rect绘制每个货位。 - 货位颜色:绿色表示空闲,红色表示占用。
- 货位旁显示文字"空闲"或"占用"。
- 模拟数据推送时,随机切换部分货位的占用状态。
4. 模拟实时数据
- 使用
setInterval每 2 秒执行一次randomStatusUpdate:- 有 10% 的概率改变每个设备的状态(正常/故障/维护)
- 有 30% 的概率切换每个立库货位的占用状态
- 你可以将此函数替换为真实的 WebSocket 回调。
测试要点
- 运行页面后,立库区域出现,货位颜色随机切换。
- 几秒后,堆垛机或输送线可能会变红并开始闪烁,上方文字变为"故障"。
- 设备拖拽时,状态文本跟随移动。
- 输送线动画与之前相同。
第 4 天总结
你已经学会了:
- 给节点附加动态文本并保持跟随
- 根据业务状态修改节点外观
- 使用定时器实现循环动画(故障闪烁)
- 管理多个节点的状态并批量更新
- 构建简单的立库可视化组件并实时响应数据
明天(第 5 天)我们将把所有功能整合为一个完整的 WCS 监控面板,包含设备图元库、属性面板、编辑/监控双模式切换,并导出你最初那样的 JSON 数据。