大家好,我是张张!近期深度参与了一个工业自动化领域的落地项目,颇有感触。客户期望在产线末端构建一套自动化的装箱标签生成与管理系统,其核心业务流程如下:
工件经由传送带流转至指定工位后,由工业视觉系统自动完成识别与检测。操作人员依据视觉系统反馈的实时检测结果,将工件分拣入箱。待装箱数量达到预设标准时,系统将自动触发打印机制,生成一枚包含完整装箱信息的二维码/条码标签,以供粘贴于箱体。与此同时,该标签所对应的数据记录会被自动存入后台数据库,后续可通过PDA扫码枪快速检索,精准调取该装箱批次的详细溯源信息。
该方案的核心价值亮点在于:实现了检测结果与物理标签的实时同步转化,打通了从数据采集到物理输出的最后一环,在提升现场作业效率的同时,也为产品全流程追溯提供了可靠的数据支撑。
核心实现讲解
- 核心技术
- vue.js 2.x:前端框架
- Element UI:组件库
- SSE(Server-Sent Events):服务器推送技术
- Zebra Browser Print:斑马打印机服务
- Vuex:状态管理
技术原理图:
1、先封装一个SSE管理器来处理与服务器的连接:
import { getToken } from '@/utils/auth'
class SSEDataParser {
constructor() {
this.buffer = '';
this.currentEvent = {
event: 'message',
data: '',
id: null,
retry: null
};
}
parse(chunk) {
this.buffer += chunk;
const events = [];
const lines = this.buffer.split('\n');
// 保留最后一行(可能不完整)
this.buffer = lines.pop() || '';
for (const line of lines) {
const event = this.parseLine(line.trim());
if (event) {
events.push(event);
}
}
return events;
}
parseLine(line) {
if (line === '') {
// 空行表示事件结束
if (this.currentEvent.data || this.currentEvent.id || this.currentEvent.retry) {
const completedEvent = { ...this.currentEvent };
this.resetCurrentEvent();
return completedEvent;
}
return null;
}
if (line.startsWith('id:')) {
this.currentEvent.id = line.substring(3).trim();
} else if (line.startsWith('retry:')) {
const retryValue = parseInt(line.substring(6).trim());
if (!isNaN(retryValue)) {
this.currentEvent.retry = retryValue;
}
} else if (line.startsWith('data:')) {
this.currentEvent.data = line.substring(5).trim();
} else if (line.startsWith('event:')) {
this.currentEvent.event = line.substring(6).trim();
}
return null;
}
resetCurrentEvent() {
this.currentEvent = {
event: 'message',
data: '',
id: null,
retry: null
};
}
clearBuffer() {
this.buffer = '';
this.resetCurrentEvent();
}
}
class CustomSSEManager {
constructor() {
this.controller = null;
this.listeners = {};
this.isConnected = false;
this.parser = new SSEDataParser();
this.reader = null;
this._store = null;
this.messageQueue = [];
// 延迟获取 store
this.initStore();
}
async initStore() {
try {
// 动态导入 store
const storeModule = await import('@/store');
this._store = storeModule.default;
console.log('✅ store 动态导入成功');
// 处理消息队列
this.processMessageQueue();
} catch (error) {
console.error('❌ store 动态导入失败:', error);
// 如果动态导入失败,尝试从 window 获取
setTimeout(() => {
this.tryGetStoreFromWindow();
}, 1000);
}
}
// 从 window 获取 store
tryGetStoreFromWindow() {
try {
// Vue DevTools 中可能可以获取到
if (window.__VUE_DEVTOOLS_GLOBAL_HOOK__?.store) {
this._store = window.__VUE_DEVTOOLS_GLOBAL_HOOK__.store;
console.log('✅ 从 window 获取到 store');
this.processMessageQueue();
} else {
console.warn('⚠️ 无法从 window 获取 store,2秒后重试');
setTimeout(() => this.tryGetStoreFromWindow(), 2000);
}
} catch (e) {
console.error('从 window 获取 store 失败:', e);
}
}
get store() {
return this._store;
}
/**
* 连接SSE
*/
async connect(url, options = {}) {
try {
this.close();
const token = getToken();
this.controller = new AbortController();
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
},
signal: this.controller.signal
});
if (!response.ok) {
throw new Error(`SSE连接失败: ${response.status}`);
}
this.isConnected = true;
if (options.onOpen) {
options.onOpen();
}
// 处理流数据
await this.processStream(response.body, options);
} catch (error) {
console.error('SSE连接失败:', error);
if (options.onError) {
options.onError(error);
}
}
}
/**
* 处理服务器推送的数据流
*/
async processStream(readableStream, options) {
const reader = readableStream.getReader();
const decoder = new TextDecoder();
try {
while (this.isConnected) {
const { done, value } = await reader.read();
if (done) {
console.log('SSE流结束');
break;
}
const chunk = decoder.decode(value, { stream: true });
this.processChunk(chunk, options);
}
} catch (error) {
// console.error('处理SSE流错误:', error);
if (options.onError) {
options.onError(error);
}
}
}
/**
* 处理SSE数据行
*/
processChunk(chunk, options) {
const events = this.parser.parse(chunk);
events.forEach(event => {
this.handleParsedEvent(event, options);
});
}
/**
* 保存消息到Vuex
*/
saveToVuex(message) {
try {
if (!this.store) {
console.warn('Vuex store不存在');
return;
}
if (!this.store.state?.sse) {
console.warn('sse模块未注册到Vuex');
return;
}
// **重点:使用 commit 保存消息**
this.store.commit('sse/ADD_MESSAGE', message);
// **如果有打印内容,也保存到 printContent**
if (message.printContent || message.content) {
this.store.commit('sse/UPDATE_PRINT_CONTENT', message.printContent || message.content);
}
console.log('消息已保存到Vuex:', message);
} catch (error) {
console.error('保存到Vuex失败:', error);
// 如果commit失败,尝试使用dispatch
try {
this.store.dispatch('sse/addMessage', message);
} catch (dispatchError) {
console.error('dispatch也失败:', dispatchError);
}
}
}
processMessageQueue() {
if (this.messageQueue.length === 0 || !this._store) return;
this.messageQueue.forEach(msg => {
try {
if (this._store.state?.sse) {
this._store.commit('sse/ADD_MESSAGE', msg);
}
} catch (e) {
console.error('处理队列消息失败:', e);
}
});
this.messageQueue = [];
}
handleParsedEvent(event, options) {
try {
const data = event.data ? JSON.parse(event.data) : {};
// 构建完整的消息对象
const message = {
...data,
_sse: {
id: event.id, // afdb7713-bd9e-43d0-a908-aa442cf6f231
retry: event.retry, // 60000
eventType: event.event, // message
timestamp: Date.now()
}
};
console.log('处理消息:', message);
this.saveToVuex(message);
// 触发通用消息回调
if (options.onMessage) {
options.onMessage(message);
}
// 根据消息类型触发特定事件
this.triggerEventListeners('message', message);
// 如果消息有type字段,也触发对应类型的事件
if (data.type) {
this.triggerEventListeners(`type_${data.type}`, message);
}
} catch (error) {
console.error('处理SSE事件失败:', error, '原始事件:', event);
}
}
addEventListener(eventName, callback) {
if (!this.listeners[eventName]) {
this.listeners[eventName] = [];
}
this.listeners[eventName].push(callback);
}
triggerEventListeners(eventName, data) {
if (this.listeners[eventName]) {
this.listeners[eventName].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`事件 ${eventName} 监听器错误:`, error);
}
});
}
}
/**
* 关闭连接
*/
close() {
this.isConnected = false;
if (this.controller) {
this.controller.abort();
this.controller = null;
}
if (this.reader) {
this.reader.cancel().catch(error => {
// 忽略取消读取器的错误,包括 AbortError
if (error.name !== 'AbortError') {
console.warn('取消读取器时出现错误:', error);
}
});
this.reader = null;
}
if (this.parser && this.parser.clearBuffer) {
this.parser.clearBuffer();
}
// 修复:直接赋值为空对象,而不是调用 clear()
this.listeners = {};
console.log('SSE连接已关闭');
}
}
const customSSEManager = new CustomSSEManager();
export default customSSEManager;
export const printWithIframe = (data) => {
return customSSEManager.printWithIframe(data);
};
export const autoPrint = (message) => {
return customSSEManager.autoPrint(message);
};
2、使用vueX管理SSE消息状态,实现跨组件数据共享:
const state = {
sseMessages: [], // 所有SSE消息
latestMessage: null, // 最新消息
ngMessages: [], // 类型为2的NG消息
okMessages: [], // 类型为1的OK消息
printContent: null
}
const mutations = {
ADD_MESSAGE: (state, message) => {
state.sseMessages.unshift(message);
state.latestMessage = message;
// 根据类型分类存储
if (message.type === '2') {
state.ngMessages.unshift(message);
}
// 如果消息包含打印内容,保存到printContent
if (message.printContent) {
try {
// 如果printContent是字符串,尝试解析
if (typeof message.printContent === 'string') {
const parsedContent = JSON.parse(message.printContent);
state.printContent = parsedContent; // 存储解析后的对象
console.log('printContent已解析:', parsedContent);
} else {
state.printContent = message.printContent;
}
} catch (e) {
state.printContent = message.printContent;
}
} else if (message.content) {
state.printContent = message.content;
}
},
// 更新打印内容
UPDATE_PRINT_CONTENT: (state, content) => {
state.printContent = content;
console.log('打印内容已更新:', content);
},
// 清空所有消息
CLEAR_MESSAGES: (state) => {
state.sseMessages = [];
state.latestMessage = null;
state.ngMessages = [];
state.okMessages = [];
state.printContent = null;
console.log('所有SSE消息已清空');
},
// 清空NG消息
CLEAR_NG_MESSAGES: (state) => {
state.ngMessages = [];
},
// 清空OK消息
CLEAR_OK_MESSAGES: (state) => {
state.okMessages = [];
},
const actions = {
// 添加消息
addMessage({ commit }, message) {
commit('ADD_MESSAGE', message);
return message;
},
// 更新打印内容
updatePrintContent({ commit }, content) {
commit('UPDATE_PRINT_CONTENT', content);
return content;
},
// 获取最新的N条消息
getRecentMessages({ state }, count = 10) {
return state.sseMessages.slice(0, count);
},
// 根据类型获取消息
getMessagesByType({ state }, type) {
if (type === '2') return state.ngMessages;
if (type === '1') return state.okMessages;
return state.sseMessages;
}
}
export default {
namespaced: true,
mutations,
state,
actions
}
3、打印机组件封装
<template>
<div class="zebra-print-page">
<!-- 打印机状态显示 -->
<div class="printer-status">
<span>打印机状态:</span>
<span :class="{'connected': selectedDevice, 'disconnected': !selectedDevice}">
{{ selectedDevice ? '已连接' : '未连接' }}
</span>
<span v-if="selectedDevice" class="printer-uid">({{ selectedDeviceUid }})</span>
</div>
</div>
</template>
<script>
export default {
name: 'ZD888Print',
data() {
return {
selectedDevice: null, // 当前选中的打印机实例
devices: [], // 所有打印机列表
selectedDeviceUid: '', // 选中打印机的UID
alerts: [], // 存储所有消息的数组
refreshing: false // 刷新状态
};
},
mounted() {
this.setup();
},
methods: {
showAlert(message, type = 'info', title = '提示') {
this.alerts.push({
title: title,
type: type,
description: message,
closable: true
});
setTimeout(() => {
if (this.alerts.length > 0) {
this.alerts.shift();
}
}, 5000);
},
removeAlert(index) {
this.alerts.splice(index, 1);
},
setup() {
if (!window.BrowserPrint) {
this.showAlert('BrowserPrint未加载,请检查脚本引入!', 'error', '错误');
return;
}
BrowserPrint.getDefaultDevice("printer", (device) => {
if (device) {
this.selectedDevice = device;
this.selectedDeviceUid = device.uid;
this.showAlert('打印机连接成功!', 'success', '成功');
}
}, (error) => {
this.showAlert(error, 'error', '错误');
});
}
}
};
</script>
4、页面调用
<template>
<div class="app-container">
<ZD888Print ref="zd888print"></ZD888Print>
</div>
</template>
<script>
import ZD888Print from '@/components/ZD888Print'
import { mapState } from 'vuex'
export default{
components:{ ZD888Print },
data(){
return{
refreshTimer: null,
printing: false
}
},
computed: {
...mapState('sse', ['printContent']),
},
watch: {
// 监听printContent变化,收到消息直接打印
printContent: {
async handler(newVal) {
if (!newVal || this.printing) return;
await this.convertAndPrint(newVal);
},
immediate: true
}
},
methods:{
// 转换内容为ZPL并打印
async convertAndPrint(content) {
if (!content) {
this.$message.warning('打印内容为空');
return;
}
this.printing = true;
try {
// 检查打印机是否连接
if (!this.$refs.zd888print || !this.$refs.zd888print.selectedDevice) {
this.$message.error('打印机未连接,请检查打印机状态');
return;
}
// 根据内容类型生成不同的ZPL指令
let zplCommand = this.generateZPL(content);
// 发送到打印机
await this.sendToPrinter(zplCommand);
this.$message.success('打印成功');
} catch (error) {
console.error('打印失败:', error);
this.$message.error('打印失败:' + error.message);
} finally {
this.printing = false;
}
},
// 生成ZPL指令
generateZPL(data) {
// 如果传入的是字符串,尝试解析JSON
let printData = data;
if (typeof data === 'string') {
try {
printData = JSON.parse(data);
} catch (e) {
// 如果不是JSON,当作普通文本处理
printData = { text: data };
}
}
// 根据数据类型生成不同的ZPL模板
if (printData.boxCode || printData.productCode) {
// 装箱码/产品码打印模板
return this.generateBoxLabelZPL(printData);
} else if (printData.text) {
// 普通文本打印模板
return this.generateTextZPL(printData.text);
} else {
// 默认模板
return this.generateDefaultZPL(printData);
}
},
// 生成装箱标签ZPL
generateBoxLabelZPL(data, options = {}) {
const boxCode = data.boxCode || data.productCode || '未知';
const lineName = data.lineName || '1#线';
const productModel = data.productModel || 'LHR117A';
const productQty = data.productQty || data.count || 0;
const qualifiedQty = data.qualifiedQty || data.count || 0;
// 二维码内容(可以自定义)
const qrCodeContent = data.qrContent || boxCode;
// 布局参数
const labelWidth = options.labelWidth || 830; // 标签宽度
const labelHeight = options.labelHeight || 650; // 标签高度
const leftMargin = options.leftMargin || 85; // 左边距
const topMargin = options.topMargin || 45; // 上边距
const rowHeight = options.rowHeight || 65; // 行高
const titleFontSize = options.titleFontSize || 22; // 标题字体大小
const valueFontSize = options.valueFontSize || 24; // 值字体大小
const qrSize = options.qrSize || 6; // 二维码大小
// 计算文字垂直居中偏移量
const titleVerticalOffset = Math.floor((rowHeight - titleFontSize) / 2);
const valueVerticalOffset = Math.floor((rowHeight - valueFontSize) / 2);
// 计算表格尺寸
const col1Width = 140; // 第一列宽度(标题列)
const col2Width = 290; // 第二列宽度(值列)- 保持不变
const col3Width = 230; // 第三列宽度(二维码列)- 保持不变
const tableWidth = col1Width + col2Width + col3Width; // 660
// 确保表格宽度不超过标签宽度
const maxTableWidth = labelWidth - leftMargin * 2; // 830 - 170 = 660
const adjustedTableWidth = Math.min(tableWidth, maxTableWidth); // 660
const tableHeight = rowHeight * 5; // 325
// 计算各列X坐标
const col1X = leftMargin;
const col2X = leftMargin + col1Width;
const col3X = leftMargin + col1Width + col2Width;
// 计算右侧边框位置(二维码区域右侧竖线)
const rightBorderX = leftMargin + adjustedTableWidth;
// 估算二维码宽度和高度
const estimatedQrSize = qrSize * 22; // 132
// 计算二维码Y坐标(居中显示)
const qrY = topMargin + (tableHeight - estimatedQrSize) / 2;
// 计算二维码在第三列内的居中位置
// 可用空间 = 第三列宽度 - 二维码宽度
const availableSpace = col3Width - estimatedQrSize; // 230 - 132 = 98
// 左右边距相等,使二维码完全居中
const sideMargin = Math.floor(availableSpace / 2); // 49
// 二维码X坐标(在第三列内完全居中)
const qrX = col3X + sideMargin;
// 线条粗细
const borderThickness = 3;
return `^XA
^SEE:GB18030.DAT^FS
^CWZ,E:SIMSUN.FNT
^CI28
^JMA
^LL${labelHeight}
^PW${labelWidth}
^MD10
^PR2
^PON
^LRN
^LH0,0
^FO${leftMargin},${topMargin}
^GB${adjustedTableWidth},0,${borderThickness}^FS
^FO${leftMargin},${topMargin + tableHeight}
^GB${adjustedTableWidth},0,${borderThickness}^FS
^FO${leftMargin},${topMargin}
^GB0,${tableHeight},${borderThickness}^FS
^FO${col2X},${topMargin}
^GB0,${tableHeight},${borderThickness}^FS
^FO${col3X},${topMargin}
^GB0,${tableHeight},${borderThickness}^FS
^FO${rightBorderX},${topMargin}
^GB0,${tableHeight},${borderThickness}^FS
^FO${leftMargin},${topMargin + rowHeight}
^GB${col1Width + col2Width},0,${borderThickness}^FS
^FO${leftMargin},${topMargin + rowHeight * 2}
^GB${col1Width + col2Width},0,${borderThickness}^FS
^FO${leftMargin},${topMargin + rowHeight * 3}
^GB${col1Width + col2Width},0,${borderThickness}^FS
^FO${leftMargin},${topMargin + rowHeight * 4}
^GB${col1Width + col2Width},0,${borderThickness}^FS
^FO${col1X + 10},${topMargin + titleVerticalOffset}
^AZN,${titleFontSize},${titleFontSize}
^FD装箱编码^FS
^FO${col2X + 10},${topMargin + valueVerticalOffset}
^AZN,${valueFontSize},${valueFontSize}
^FD${boxCode}^FS
^FO${col1X + 10},${topMargin + rowHeight + titleVerticalOffset}
^AZN,${titleFontSize},${titleFontSize}
^FD装配线^FS
^FO${col2X + 10},${topMargin + rowHeight + valueVerticalOffset}
^AZN,${valueFontSize},${valueFontSize}
^FD${lineName}^FS
^FO${col1X + 10},${topMargin + rowHeight * 2 + titleVerticalOffset}
^AZN,${titleFontSize},${titleFontSize}
^FD产品型号^FS
^FO${col2X + 10},${topMargin + rowHeight * 2 + valueVerticalOffset}
^AZN,${valueFontSize},${valueFontSize}
^FD${productModel}^FS
^FO${col1X + 10},${topMargin + rowHeight * 3 + titleVerticalOffset}
^AZN,${titleFontSize},${titleFontSize}
^FD装箱数量^FS
^FO${col2X + 10},${topMargin + rowHeight * 3 + valueVerticalOffset}
^AZN,${valueFontSize},${valueFontSize}
^FD${productQty}^FS
^FO${col1X + 10},${topMargin + rowHeight * 4 + titleVerticalOffset}
^AZN,${titleFontSize},${titleFontSize}
^FD合格数量^FS
^FO${col2X + 10},${topMargin + rowHeight * 4 + valueVerticalOffset}
^AZN,${valueFontSize},${valueFontSize}
^FD${qualifiedQty}^FS
^FO${qrX},${qrY}
^BQN,2,${qrSize}
^FDQA,${qrCodeContent}^FS
^XZ`;
},
// 生成普通文本ZPL
generateTextZPL(text) {
return `^XA
^SEE:GB18030.DAT^FS
^CWZ,E:SIMSUN.FNT
^CI28
^JMA
^LL200
^PW700
^MD10
^PR2
^PON
^LRN
^LH0,0
^FO50,50
^AZN,36,36
^FD${text}^FS
^XZ`;
},
// 生成默认ZPL
generateDefaultZPL(data) {
// 将对象转换为字符串显示
const text = typeof data === 'object' ? JSON.stringify(data) : String(data);
return this.generateTextZPL(text.substring(0, 50)); // 限制长度
},
// 发送到打印机
sendToPrinter(zplCommand) {
return new Promise((resolve, reject) => {
if (!this.$refs.zd888print || !this.$refs.zd888print.selectedDevice) {
reject(new Error('打印机未连接'));
return;
}
// 使用ZD888Print组件的发送方法
this.$refs.zd888print.selectedDevice.send(zplCommand,
() => {
resolve();
},
(error) => {
reject(error);
}
);
});
},
// 格式化时间
formatTime(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
},
// 快速打印所有数据
async handleQuickPrint(){
getBoxPrintingData().then(res =>{
this.convertAndPrint(res.data);
})
},
// 延迟函数
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
}
</script>
结束啦,从视觉识别到标签生成,看似简单的流程闭环,实则解决了产线信息断点的核心痛点。当每一次装箱都能被精准记录、每一件产品都能被追溯溯源,企业构建的不仅是一套打印系统,更是一道质量防线和一张效率网络。这正是工业数字化落地的真实写照。
纵横工业互联网团队是河南863一支专注于工业数字化的团队,是深耕工业数字化转型领域的专业技术与解决方案服务商,聚焦工业企业智能化升级核心需求,打造了全栈式、可落地的工业互联网产品与服务体系。我们构建了自主可控的五大核心产品体系,涵盖面向产业集聚区 / 工业园区的产业集聚区工业互联网管理平台,以及面向工业企业全生产流程的物联网平台、能耗能碳管理平台、设备管理系统、MES 生产制造执行系统。 我们团队累计接入工业设备 10 万余台,覆盖 100 余类设备类型,适配 840 余种工业协议,深耕烟草、高端装备、汽车零部件、新能源电力、煤炭能源、家电制造等数十个核心工业领域,服务 50 余家行业头部企业与产业园区主体,沉淀了海量的项目落地经验与行业 Know-How,具备全链路数据采集、计算、应用与数字化运营能力,可为产业园区、工业企业提供一站式工业互联网解决方案,助力园区产业升级、治理提效,助力企业降本增效、精益管理、绿色转型。
“关于打印标签码的落地实践,欢迎通过我的掘金主页联系交流~”