以访客标签打印为例,详解 DYMO Connect Framework、Label XML、动态数据绑定、Base64 图片、打印机选择与异常处理
一、前言
在访客管理、门禁系统、前台登记等场景中,经常会遇到这样的需求:
用户完成访客登记后,系统自动将访客信息打印到 DYMO 标签打印机上。
最开始看起来非常简单:
dymo.label.framework.printLabel(...)
调用一下就可以。
但真正做到生产环境以后,会发现问题远比想象中复杂:
- DYMO Connect 是否安装?
- DYMO Framework 是否正常运行?
- 当前电脑有没有打印机?
- 使用哪一台打印机?
- Label XML 模板从哪里来?
- 后端返回的数据如何绑定到 Label XML?
- 二维码是 URL 还是 Base64?
- Base64 前面的
data:image/png;base64,要不要保留? - 日期应该由前端生成还是 DYMO 自己生成?
- 用户连续点击打印怎么办?
- 打印机没纸怎么办?
- 打印机离线怎么办?
- DYMO 服务挂掉怎么办?
- 如何让业务页面不关心这些底层细节?
因此,我最终采用了一个比较清晰的分层设计。
二、最终架构
整个打印流程设计成:
┌────────────────────────────┐
│ Vue 业务页面 │
│ │
│ printVisitorTicket(data) │
└─────────────┬──────────────┘
↓
┌────────────────────────────┐
│ dymoService.js │
│ │
│ Framework 检测 │
│ 打印机选择 │
│ 模板加载 │
│ 数据绑定 │
│ 打印锁 │
│ 异常处理 │
└─────────────┬──────────────┘
↓
┌────────────────────────────┐
│ dymoTemplateBinding.js │
│ │
│ Label XML │
│ TextObject │
│ ImageObject │
│ DateTimeObject │
└─────────────┬──────────────┘
↓
┌────────────────────────────┐
│ Label XML │
│ │
│ Company │
│ UserName │
│ Date │
│ QrCode │
│ Text1 │
│ Text2 │
│ Text3 │
└─────────────┬──────────────┘
↓
┌────────────────────────────┐
│ DYMO Connect Framework │
└─────────────┬──────────────┘
↓
┌────────────────────────────┐
│ DYMO 打印机 │
└────────────────────────────┘
这样设计以后,Vue 页面完全不需要知道 DYMO Framework 的细节。
业务页面只需要:
const result = await printVisitorTicket(response.data);
三、项目目录
最终项目结构:
src/
├── services/
│ ├── dymoService.js
│ └── dymoTemplateBinding.js
│
└── templates/
└── visitor-ticket.label
其中:
visitor-ticket.label
负责:
标签长什么样。
dymoTemplateBinding.js
负责:
数据应该填到标签的哪个对象里面。
dymoService.js
负责:
怎么检测 DYMO、怎么找打印机、怎么执行打印。
这三个职责不要混在一起。
四、Vue 项目引入 DYMO Label 模板
因为 .label 本质上是 XML 文件,所以 Vite 可以直接使用:
import visitorLabelTemplate from "@/templates/visitor-ticket.label?raw";
这里的:
?raw
非常重要。
它的作用是:
不让 Vite 把
.label当普通资源 URL,而是直接把文件内容作为字符串加载。
因此:
console.log(visitorLabelTemplate);
拿到的就是完整 XML。
五、为什么要单独设计 dymoTemplateBinding.js
假设 Label 模板里面有
<TextObject>
<Name>Company</Name>
...
</TextObject>
后端返回:
{
Company: "上海某某科技有限公司"
}
我们需要把:
Company
和:
上海某某科技有限公司
建立关系。
因此定义:
const bindingMap = {
Company: { type: "text" },
UserName: { type: "text" },
Date: { type: "date" },
QrCode: { type: "image" },
Text1: { type: "text" },
Text2: { type: "text" },
Text3: { type: "text" },
};
这样以后新增打印字段非常简单。
例如增加:
Department: {
type: "text",
}
就可以继续扩展。
六、文本对象绑定
最基础的就是:
function setTextObjectValue(labelXml, objectName, value) {
const safeValue = escapeXmlText(value);
return replaceObjectBlock(
labelXml,
objectName,
(block) => {
let replaced = false;
return block.replace(
/(<TextSpan\b[^>]*>[\s\S]*?<Text\b[^>]*>)([\s\S]*?)(<\/Text>)/gi,
(match, prefix, oldValue, suffix) => {
if (!replaced) {
replaced = true;
return `${prefix}${safeValue}${suffix}`;
}
return `${prefix}${suffix}`;
}
);
}
);
}
这里有一个容易忽略的问题:
不能直接把用户数据拼接到 XML 中。
例如:
`${value}`
如果 value 里面存在:
&
<
>
就可能破坏 XML。
因此需要:
function escapeXmlText(value) {
if (value === null || value === undefined) {
return "";
}
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
例如:
A & B
最终会变成:
A & B
七、二维码 Base64 是这次踩坑比较严重的地方
后端可能返回:
data:image/png;base64,iVBORw0KGgo...
但是 DYMO Label XML 的:
<Data>
...
</Data>
里面需要的是:
iVBORw0KGgo...
而不是:
data:image/png;base64,iVBORw0KGgo...
所以需要进行处理。
function normalizeBase64Image(value) {
if (value === null || value === undefined) {
return "";
}
let base64 = String(value).trim();
if (!base64) {
return "";
}
const dataUrlRegex =
/^data:image\/[a-zA-Z0-9.+-]+;base64,/i;
while (dataUrlRegex.test(base64)) {
base64 = base64.replace(dataUrlRegex, "");
}
base64 = base64.replace(/\s+/g, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) {
throw new Error(
"QrCode 图片数据不是合法的 Base64。"
);
}
if (base64.length % 4 !== 0) {
throw new Error(
"QrCode Base64 数据长度无效。"
);
}
return base64;
}
然后:
function setImageObjectValue(
labelXml,
objectName,
value
) {
const base64 = normalizeBase64Image(value);
return replaceObjectBlock(
labelXml,
objectName,
(block) => {
const dataRegex =
/(<Data\b[^>]*>)[\s\S]*?(<\/Data>)/i;
if (!dataRegex.test(block)) {
return block;
}
return block.replace(
dataRegex,
`$1${base64}$2`
);
}
);
}
八、dymoService.js
模板数据绑定完成以后,进入真正的打印服务。
首先获取 DYMO Framework:
function getDymoFramework() {
if (
typeof window === "undefined" ||
!window.dymo ||
!window.dymo.label ||
!window.dymo.label.framework
) {
return null;
}
return window.dymo.label.framework;
}
为什么不直接:
window.dymo.label.framework
?
因为 Vue 页面可能在 Framework 尚未加载的时候执行。
所以需要先判断:
window.dymo
然后:
window.dymo.label
最后:
window.dymo.label.framework
九、检测 DYMO 服务
function check() {
const framework = getDymoFramework();
if (!framework) {
return {
success: false,
code: "DYMO_NOT_AVAILABLE",
message:
"未检测到 DYMO Connect Framework,请确认 DYMO Connect 已安装并正在运行。",
printers: [],
};
}
let printers = [];
try {
printers = framework.getPrinters() || [];
} catch (error) {
return {
success: false,
code: "GET_PRINTERS_FAILED",
message:
"无法获取 DYMO 打印机列表,请确认 DYMO Connect 服务正常运行。",
error,
printers: [],
};
}
...
}
这样业务页面就不需要处理:
window.dymo
也不需要处理:
framework.getPrinters()
十、打印机自动选择
打印机选择也放在服务层。
function resolvePrinter(options = {}) {
const printers = getPrinters();
if (!printers.length) {
throw new Error(
"未检测到 DYMO 打印机。"
);
}
const printerName =
options.printerName ||
DYMO_CONFIG.printerName;
if (printerName) {
const printer =
findPrinter(
printers,
printerName
);
if (!printer) {
throw new Error(
`未找到指定的 DYMO 打印机:${printerName}`
);
}
return printer;
}
const printer =
getDefaultPrinter(printers);
if (!printer) {
throw new Error(
"无法确定 DYMO 默认打印机。"
);
}
return printer;
}
这样可以支持两种模式:
指定打印机
await printVisitorTicket(data, {
printerName: "DYMO LabelWriter 450",
});
自动选择
await printVisitorTicket(data);
十一、为什么需要打印锁
访客登记页面很容易出现这种情况:
用户连续点击:
打印
打印
打印
打印
如果不限制,就可能连续发送多个打印任务。
因此:
let printing = false;
开始打印:
function acquirePrintLock() {
if (printing) {
throw new Error(
"DYMO 正在打印,请勿重复提交。"
);
}
printing = true;
}
结束后:
function releasePrintLock() {
printing = false;
}
最终:
async function printLabel(
labelXml,
options = {}
) {
acquirePrintLock();
try {
...
} finally {
releasePrintLock();
}
}
这里 finally 非常重要。
因为无论打印:
成功
还是:
失败
都必须释放锁。
十二、真正执行打印的地方
前面的所有代码其实都在做准备。
真正执行打印的是:
framework.printLabel(
printerName,
"",
labelXml,
""
);
我又在外面包装了一层 Promise:
function printLabelAsync(
framework,
printerName,
labelXml,
options = {}
) {
const timeout =
Number(options.timeout) ||
DYMO_CONFIG.printTimeout;
return new Promise(
(resolve, reject) => {
let completed = false;
const finish = (
callback,
value
) => {
if (completed) return;
completed = true;
clearTimeout(timer);
callback(value);
};
const timer = setTimeout(() => {
finish(
reject,
new Error(
"DYMO 打印请求超时,请检查 DYMO Connect 和打印机状态。"
)
);
}, timeout);
try {
const result =
framework.printLabel(
printerName,
"",
labelXml,
""
);
finish(resolve, result);
} catch (error) {
finish(reject, error);
}
}
);
}
这样可以避免打印请求一直挂着。
十三、为什么要设计 printVisitorTicket()
最终我没有让业务页面直接调用:
printLabel()
而是再封装一层:
async function printVisitorTicket(
data = {},
options = {}
) {
const checkResult = check();
if (!checkResult.success) {
return checkResult;
}
let templateXml;
try {
templateXml =
getVisitorLabelTemplate();
validateTemplate(templateXml);
} catch (error) {
return {
success: false,
code: "TEMPLATE_ERROR",
message:
error?.message ||
"DYMO 标签模板加载失败。",
error,
};
}
let labelXml;
try {
labelXml =
createVisitorLabel(
templateXml,
data
);
} catch (error) {
return {
success: false,
code: "BIND_DATA_FAILED",
message:
error?.message ||
"DYMO 标签数据绑定失败。",
error,
};
}
return printLabel(
labelXml,
options
);
}
它实际上就是整个系统的:
业务入口。
十四、业务页面最终有多简单?
后端接口返回:
const response = await createVisitor();
假设:
response.data = {
Company: "上海某某科技有限公司",
UserName: "张三",
Date: "2026-09-11",
QrCode: "data:image/png;base64,iVBORw0KGgo...",
Text1: "访客",
Text2: "A栋",
Text3: "欢迎光临",
};
页面只需要:
const result =
await printVisitorTicket(
response.data
);
if (!result.success) {
ElMessage.error(
result.message ||
"打印失败,请联系管理员"
);
return;
}
ElMessage.success(
"打印成功"
);
这就是整个封装最大的价值。
业务页面完全不需要知道:
DYMO Framework
Label XML
Base64
打印机名称
打印锁
XML 转义
模板对象
十五、为什么错误不要在页面里面判断
不推荐:
if (
error.message.includes("paper")
) {
...
}
因为页面不应该关心 DYMO 的底层错误信息。
应该让服务层统一转换。
例如:
DYMO_NOT_AVAILABLE
NO_PRINTER
PRINTER_NOT_FOUND
PRINTER_OFFLINE
PRINTER_NO_PAPER
PRINT_TIMEOUT
PRINT_FAILED
TEMPLATE_ERROR
BIND_DATA_FAILED
然后页面只负责:
switch (result.code) {
case "PRINTER_NO_PAPER":
ElMessage.error(
"打印机缺纸,请联系管理员"
);
break;
case "PRINTER_OFFLINE":
ElMessage.error(
"打印机离线,请联系管理员"
);
break;
case "DYMO_NOT_AVAILABLE":
ElMessage.error(
"打印服务异常,请联系管理员"
);
break;
default:
ElMessage.error(
"打印失败,请联系管理员"
);
}
不过这里有一个需要特别说明的地方:
目前不能仅凭 getPrinters() 就断言一定能够提前判断“缺纸”。
DYMO Framework 是否会提供明确的纸张状态,以及无纸时返回什么错误,需要结合实际打印机和 Framework 的错误信息确认。
所以正确的开发顺序应该是:
真实模拟缺纸
↓
获取 DYMO 实际错误
↓
分析 error / message
↓
统一转换 code
↓
页面显示中文提示
而不是提前猜一个:
NO_PAPER
十六、这次遇到的 DYMO 服务连接问题
开发过程中还遇到过:
ERR_CONNECTION_REFUSED
例如:
https://127.0.0.1:41952/DYMO/DLS/Printing/PrintLabel
这个错误和 Label XML 本身没有关系。
它代表:
浏览器尝试访问本机 DYMO Web Service,但是对应服务没有正常监听。
所以排查应该分成两层:
第一层
浏览器
↓
DYMO Web Service
确认:
DYMO Connect 是否运行
然后:
第二层
DYMO Framework
↓
打印机
确认:
打印机是否存在
打印机是否在线
打印机是否正常
不要把这两类问题混在一起。
十七、最终形成三层结构
到这里整个设计就比较清晰了。
第一层:业务层
await printVisitorTicket(data);
负责:
我要打印访客标签。
第二层:服务层
dymoService.js
负责:
DYMO Framework
打印机
模板
打印
异常
第三层:模板绑定层
dymoTemplateBinding.js
负责:
Company
UserName
Date
QrCode
Text1
Text2
Text3
与 XML 对象之间的映射。
十八、最终调用关系
最终完整调用链:
Vue 页面
│
│ printVisitorTicket(response.data)
↓
dymoService.js
│
├── check()
│
├── getVisitorLabelTemplate()
│
├── validateTemplate()
│
├── createVisitorLabel()
│ │
│ ↓
│ dymoTemplateBinding.js
│ │
│ ↓
│ Label XML
│
└── printLabel()
│
├── acquirePrintLock()
│
├── resolvePrinter()
│
├── printLabelAsync()
│
└── framework.printLabel()
│
↓
DYMO Connect
│
↓
DYMO Printer
十九、这种封装最大的意义
最终业务代码从原来的:
业务页面
↓
DYMO Framework
↓
打印机
变成:
业务页面
↓
printVisitorTicket(data)
↓
完成打印
以后如果需要增加:
访客标签
员工标签
车辆标签
会议标签
临时卡标签
也不需要让业务页面直接操作 DYMO。
可以继续:
printVisitorTicket(data);
printEmployeeTicket(data);
printVehicleTicket(data);
printMeetingTicket(data);
底层依然共用:
dymoService.js
dymoTemplateBinding.js
DYMO Framework
这就从一个简单的“调用打印机”,变成了一个真正可以维护和扩展的打印服务层。
二十、完整核心代码
文章最后可以放一个完整的核心代码版本,方便读者直接复制使用。
dymoTemplateBinding.js
const OBJECT_TYPES =
"TextObject|ImageObject|DateTimeObject";
function escapeXmlText(value) {
if (value === null || value === undefined) {
return "";
}
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function escapeRegExp(value) {
return String(value).replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&"
);
}
function normalizeBase64Image(value) {
if (value === null || value === undefined) {
return "";
}
let base64 = String(value).trim();
if (!base64) {
return "";
}
const dataUrlRegex =
/^data:image\/[a-zA-Z0-9.+-]+;base64,/i;
while (dataUrlRegex.test(base64)) {
base64 = base64.replace(
dataUrlRegex,
""
);
}
base64 = base64.replace(/\s+/g, "");
if (
!/^[A-Za-z0-9+/]*={0,2}$/.test(base64)
) {
throw new Error(
"QrCode 图片数据不是合法的 Base64。"
);
}
if (base64.length % 4 !== 0) {
throw new Error(
"QrCode Base64 数据长度无效。"
);
}
return base64;
}
function createNameRegex(objectName) {
const escapedName =
escapeRegExp(objectName);
return new RegExp(
`<Name\\s*>\\s*${escapedName}\\s*<\\/Name\\s*>`,
"i"
);
}
function findObjectBlock(
labelXml,
objectName
) {
if (!labelXml || !objectName) {
return null;
}
const objectRegex =
new RegExp(
`<(${OBJECT_TYPES})\\b[^>]*>[\\s\\S]*?<\\/\\1>`,
"gi"
);
const nameRegex =
createNameRegex(objectName);
let match;
while (
(match = objectRegex.exec(labelXml)) !== null
) {
const block = match[0];
if (nameRegex.test(block)) {
return {
type: match[1],
start: match.index,
end:
match.index + block.length,
block,
};
}
}
return null;
}
function replaceObjectBlock(
labelXml,
objectName,
replacer
) {
const object =
findObjectBlock(
labelXml,
objectName
);
if (!object) {
return {
success: false,
xml: labelXml,
message:
`DYMO 模板中不存在对象:${objectName}`,
};
}
const newBlock =
replacer(
object.block,
object.type
);
if (typeof newBlock !== "string") {
return {
success: false,
xml: labelXml,
message:
`DYMO 模板对象替换失败:${objectName}`,
};
}
return {
success: true,
xml:
labelXml.slice(
0,
object.start
) +
newBlock +
labelXml.slice(object.end),
};
}
function setTextObjectValue(
labelXml,
objectName,
value
) {
const safeValue =
escapeXmlText(value);
return replaceObjectBlock(
labelXml,
objectName,
(block) => {
let replaced = false;
return block.replace(
/(<TextSpan\b[^>]*>[\s\S]*?<Text\b[^>]*>)([\s\S]*?)(<\/Text>)/gi,
(
match,
prefix,
oldValue,
suffix
) => {
if (!replaced) {
replaced = true;
return `${prefix}${safeValue}${suffix}`;
}
return `${prefix}${suffix}`;
}
);
}
);
}
function setImageObjectValue(
labelXml,
objectName,
value
) {
const base64 =
normalizeBase64Image(value);
return replaceObjectBlock(
labelXml,
objectName,
(block) => {
const dataRegex =
/(<Data\b[^>]*>)[\s\S]*?(<\/Data>)/i;
if (!dataRegex.test(block)) {
return block;
}
return block.replace(
dataRegex,
`$1${base64}$2`
);
}
);
}
function setDateTimeObjectValue(
labelXml,
objectName,
value
) {
return replaceObjectBlock(
labelXml,
objectName,
(block) => block
);
}
function setObjectValue(
labelXml,
objectName,
value,
type
) {
switch (type) {
case "image":
case "ImageObject":
return setImageObjectValue(
labelXml,
objectName,
value
);
case "date":
case "datetime":
case "DateTimeObject":
return setDateTimeObjectValue(
labelXml,
objectName,
value
);
case "text":
case "TextObject":
default:
return setTextObjectValue(
labelXml,
objectName,
value
);
}
}
function bindData(
labelXml,
data = {}
) {
if (
!labelXml ||
typeof labelXml !== "string"
) {
throw new Error(
"DYMO Label XML 模板不能为空"
);
}
if (
!data ||
typeof data !== "object"
) {
throw new Error(
"DYMO 打印数据必须是对象"
);
}
let resultXml = labelXml;
const bindingMap = {
Company: {
type: "text",
},
UserName: {
type: "text",
},
Date: {
type: "date",
},
QrCode: {
type: "image",
},
Text1: {
type: "text",
},
Text2: {
type: "text",
},
Text3: {
type: "text",
},
};
Object.keys(bindingMap)
.forEach((objectName) => {
if (
!Object.prototype.hasOwnProperty.call(
data,
objectName
)
) {
return;
}
const config =
bindingMap[objectName];
const result =
setObjectValue(
resultXml,
objectName,
data[objectName],
config.type
);
if (result.success) {
resultXml = result.xml;
}
});
return resultXml;
}
function createVisitorLabel(
templateXml,
data = {}
) {
return bindData(
templateXml,
data
);
}
export {
bindData,
createVisitorLabel,
setTextObjectValue,
setImageObjectValue,
setDateTimeObjectValue,
setObjectValue,
normalizeBase64Image,
};
二十一、总结
这次 DYMO 集成最终不是简单地完成:
printLabel()
而是建立了一套完整的打印链路:
业务数据
↓
模板
↓
数据绑定
↓
Label XML
↓
打印机选择
↓
DYMO Framework
↓
打印
↓
统一错误处理
其中最重要的几个经验是:
第一,不要让业务页面直接操作 DYMO Framework。
第二,把 Label XML 数据绑定独立出来。
第三,Base64 图片一定要处理 Data URL 前缀。
第四,DateTimeObject 不要随意修改 StaticDateTime。
第五,打印过程需要防重复提交和超时控制。
第六,打印异常应该在服务层统一转换,页面只负责显示业务提示。
最终让业务代码保持简单:
const result =
await printVisitorTicket(
response.data
);
if (!result.success) {
ElMessage.error(
result.message ||
"打印失败,请联系管理员"
);
return;
}