1 封装打印的基础函数
export const zplHexEncodeUtf8 = (text: string) => {
const bytes = new TextEncoder().encode(text)
let out = ''
for (const b of bytes) out += `_${b.toString(16).padStart(2, '0').toUpperCase()}`
return out
}
export const sendZplToPrinter = async (
device: any,
zpl: string,
options?: { retries?: number; retryDelayMs?: number; timeoutMs?: number }
): Promise<void> => {
const retries = Math.max(0, Number(options?.retries ?? 0))
const retryDelayMs = Math.max(0, Number(options?.retryDelayMs ?? 0))
const timeoutMs = Math.max(0, Number(options?.timeoutMs ?? 8000))
const sendOnce = () =>
new Promise<void>((resolve, reject) => {
if (!device || typeof device.send !== 'function') {
reject(new Error('Printer device not available'))
return
}
let done = false
let timer: any
if (timeoutMs > 0) {
timer = setTimeout(() => {
if (done) return
done = true
reject(new Error('Printer send timeout'))
}, timeoutMs)
}
const finish = (err?: any) => {
if (done) return
done = true
if (timer) clearTimeout(timer)
if (err) reject(err instanceof Error ? err : new Error(String(err ?? 'send failed')))
else resolve()
}
device.send(
zpl,
() => finish(),
(err: any) => finish(err)
)
})
for (let attempt = 0; attempt <= retries; attempt++) {
try {
await sendOnce()
return
} catch (e) {
if (attempt >= retries) throw e
if (retryDelayMs > 0) await new Promise((r) => setTimeout(r, retryDelayMs))
}
}
}
export const buildOrderLabelZpl = (options: {
cardNo?: string
userId?: string | number
recognizedInfoString?: string
fontFile?: string
xOffset?: number
widthDots?: number
heightDots?: number
}) => {
const cardNo = String(options.cardNo ?? '').trim()
const userId = String(options.userId ?? '').trim()
const recognizedInfoString = String(options.recognizedInfoString ?? '').trim()
const fontFile = String(options.fontFile ?? '').trim()
const x = Math.max(0, Math.round(Number(options.xOffset ?? 0)))
const widthDots = Math.max(1, Math.round(Number(options.widthDots ?? 480)))
const heightDots = Math.max(1, Math.round(Number(options.heightDots ?? 320)))
const safeAscii = (v: string) =>
v
.replace(/[\x00-\x1F\x7F]/g, ' ')
.replace(/[\^~]/g, ' ')
.trim()
const barcodeData = safeAscii(cardNo)
const title = '订单标签'
const titleEncoded = zplHexEncodeUtf8(title)
const recognizedEncoded = zplHexEncodeUtf8(recognizedInfoString)
const lines: string[] = ['^XA', '^CI28', `^PW${widthDots}`, `^LL${heightDots}`, '^LH0,0', '^LS0']
if (barcodeData) {
lines.push('^BY2,2,80')
lines.push(`^FO${x},10^BCN,80,Y,N,N^FD${barcodeData}^FS`)
}
if (fontFile) {
lines.push(`^FO${x},105^A@N,28,28,${fontFile}^FH_^FD${titleEncoded}^FS`)
} else {
lines.push(`^FO${x},105^A0N,28,28^FD${title}^FS`)
}
if (cardNo) lines.push(`^FO${x},140^A0N,24,24^FDCARD: ${safeAscii(cardNo)}^FS`)
if (userId) lines.push(`^FO${x},170^A0N,24,24^FDUSER: ${safeAscii(userId)}^FS`)
if (recognizedInfoString) {
if (fontFile) {
lines.push(`^FO${x},200^A@N,24,24,${fontFile}^FH_^FD${recognizedEncoded}^FS`)
} else {
lines.push(`^FO${x},200^A0N,24,24^FD${safeAscii(recognizedInfoString)}^FS`)
}
}
lines.push('^XZ')
return lines.join('\n')
}
export const printOrderLabel = async (options: {
device: any
cardNo?: string
userId?: string | number
recognizedInfoString?: string
fontFile?: string
xOffset?: number
widthDots?: number
heightDots?: number
retries?: number
retryDelayMs?: number
timeoutMs?: number
}) => {
const zpl = buildOrderLabelZpl(options)
await sendZplToPrinter(options.device, zpl, {
retries: options.retries,
retryDelayMs: options.retryDelayMs,
timeoutMs: options.timeoutMs
})
return zpl
}
2 具体页面中使用:
<template>
<div class="page-wrap">
<div ref="tableWrapRef">
<el-card>
<el-table :data="rawList" row-key="id" style="width: 100%">
<el-table-column prop="orderNo" label="订单编号" min-width="140" />
<el-table-column prop="cardNo" label="卡片编号" min-width="190" />
<el-table-column label="评级款式" min-width="90">
<template #default="{ row }">
<span>{{ getCardStyleLabel(row.cardStyle) }}</span>
</template>
</el-table-column>
<el-table-column prop="lastTimeShip" label="最晚发货时间" min-width="120" />
<el-table-column prop="userId" label="用户ID" min-width="90" />
<el-table-column label="用户收货信息" min-width="140">
<template #default="{ row }">
<span>{{ row.userName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="平台寄出单号" min-width="150">
<template #default="{ row }">
<span>{{ formatLogistics(row.logisticsCompany, row.logisticsNo) }}</span>
</template>
</el-table-column>
<el-table-column label="卡片状态" min-width="90">
<template #default="{ row }">
<el-tag :type="cardStatusTagType(row.cardStatus)">{{ cardStatusStatusText[row.cardStatus] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="卡密信息" min-width="220">
<template #default="{ row }">
<span>{{ row?.recognizedInfoString ?? '--' }}</span>
</template>
</el-table-column>
<el-table-column prop="evaluateNo" label="评级编号" min-width="150" />
<el-table-column label="总分" min-width="80" align="right">
<template #default="{ row }">
<span>{{ row?.scoreThreshold ?? '--' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="260">
<template #default="{ row }">
<el-space>
<el-button link type="primary" @click="onRecognize(row)">识别卡密</el-button>
<el-dropdown trigger="click">
<el-button link type="primary">
更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="onPrintLabel(row)">打印订单标签</el-dropdown-item>
<el-dropdown-item v-if="row.backImageUrl && row.frontImageUrl" @click="onViewImage(row)">
查看图片
</el-dropdown-item>
<el-dropdown-item @click="onViewReport(row)">查看报告</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-space>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="currentPage"
@current-change="onPageChange"
/>
</div>
</el-card>
</div>
<el-dialog v-model="imageDialogVisible" title="查看图片" width="600px" center destroy-on-close>
<div class="image-viewer-container" style="text-align: center">
<el-image
:src="previewImageList[currentImageIndex]"
fit="contain"
style="max-height: 500px; max-width: 100%"
:preview-src-list="previewImageList"
:initial-index="currentImageIndex"
/>
<div v-if="previewImageList.length > 1" class="image-actions" style="margin-top: 20px">
<el-button @click="switchImage(-1)" :icon="ArrowLeft">上一张</el-button>
<span style="margin: 0 15px; font-weight: bold"
>{{ currentImageIndex + 1 }} / {{ previewImageList.length }}</span
>
<el-button @click="switchImage(1)">
下一张
<el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElLoading, ElMessage } from 'element-plus'
import { ArrowLeft, ArrowRight, ArrowDown } from '@element-plus/icons-vue'
import { getCardList } from '@/apis'
import { CardItem, CardListResult, CardStatus, cardStatusText, cardStyleText, RecognizedInfo } from './modal'
import { useScanInput } from '@/hooks/useScanInput'
import { usePrinter } from '@/hooks/usePrinter'
import { printOrderLabel } from '@/utils'
import { cardStatusStatusText } from './modal'
const router = useRouter()
const { getPrinter } = usePrinter()
const query = reactive({
query: '',
cardNo: '',
logisticsNo: '',
cardStatus: '' as '' | CardStatus,
lastTimeShip: '' as '' | string
})
const cardStatusOptions = computed(() => {
const entries = Object.entries(cardStatusText).map(([value, label]) => ({ value, label }))
return entries
})
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const rawList = ref<CardItem[]>([])
const listLoading = ref(false)
const tableWrapRef = ref<HTMLElement | null>(null)
const cardNoInputRef = ref<any>(null)
async function fetchList() {
let loadingInstance: ReturnType<typeof ElLoading.service> | undefined
try {
listLoading.value = true
if (tableWrapRef.value) {
loadingInstance = ElLoading.service({ target: tableWrapRef.value, fullscreen: false })
}
const res: any = await getCardList({
...query,
page: currentPage.value,
rows: pageSize.value
})
rawList.value = res?.rows ?? []
total.value = res?.total ?? 0
} catch (e: any) {
ElMessage.error(e?.message || '获取卡片列表失败')
rawList.value = []
total.value = 0
} finally {
listLoading.value = false
loadingInstance?.close()
}
}
onMounted(() => {
fetchList()
nextTick(() => {
cardNoInputRef.value?.focus?.()
})
})
function onSearch() {
currentPage.value = 1
fetchList()
}
function onReset() {
query.query = ''
query.cardNo = ''
query.logisticsNo = ''
query.cardStatus = ''
query.lastTimeShip = ''
currentPage.value = 1
fetchList()
}
function onPageChange(page: number) {
currentPage.value = page
fetchList()
}
function getCardStatusLabel(v: unknown) {
const key = String(v ?? '')
return cardStatusText[key] || key || '-'
}
function cardStatusTagType(v: unknown) {
const key = String(v ?? '')
if (['13', '11'].includes(key)) return 'success'
if (['10'].includes(key)) return 'warning'
// if (key === 'CLOSED') return 'info'
return ''
}
function getCardStyleLabel(v: unknown) {
const key = String(v ?? '')
return cardStyleText[key] || key || '-'
}
function formatLogistics(company?: string, no?: string) {
const c = company || '-'
const n = no || '-'
if (c === '-' && n === '-') return '-'
return `${c} ${n}`.trim()
}
function getRecognizedLabel(info?: RecognizedInfo) {
if (!info) return '-'
const sportType = typeof info.sportType === 'string' ? info.sportType : ''
const playerName = typeof info.playerName === 'string' ? info.playerName : ''
const text = `${sportType} ${playerName}`.trim()
return text || '-'
}
function onRecognize(row: CardItem) {
router.push({ path: '/card/recognize', query: { cardNo: row.cardNo, orderNo: row.orderNo, id: row.id } })
}
async function onPrintLabel(row: CardItem) {
try {
const device = await getPrinter()
const fontFile = String(localStorage.getItem('zpl_font_file_override') || '').trim() || undefined
const xOffsetRaw = 40
const xOffset = Number.isFinite(xOffsetRaw) ? xOffsetRaw : 0
await printOrderLabel({
device,
cardNo: row.cardNo,
userId: row.userId,
recognizedInfoString: (row as any)?.recognizedInfoString,
fontFile,
xOffset,
widthDots: 480,
heightDots: 320,
retries: 1,
retryDelayMs: 1000,
timeoutMs: 8000
})
ElMessage.success('打印任务已发送')
} catch (e: any) {
ElMessage.error(e?.message || '打印失败')
}
}
function onViewReport(row: CardItem) {
if (!row.cardNo) {
ElMessage.warning('缺少卡片编号')
return
}
// 跳转到单独打包的 rating.html 页面
// 使用 import.meta.env.BASE_URL 确保在非根目录部署时也能正确跳转
const baseUrl = import.meta.env.BASE_URL
// 确保 baseUrl 以 / 结尾,避免重复或缺失
const prefix = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
const url = `${prefix}rating.html?cardNo=${row.cardNo}`
window.open(url, '_blank')
}
const imageDialogVisible = ref(false)
const previewImageList = ref<string[]>([])
const currentImageIndex = ref(0)
function onViewImage(row: CardItem) {
const imgs: string[] = []
if (row.frontImageUrl) imgs.push(row.frontImageUrl)
if (row.backImageUrl) imgs.push(row.backImageUrl)
if (imgs.length === 0) {
ElMessage.warning('暂无图片')
return
}
previewImageList.value = imgs
currentImageIndex.value = 0
imageDialogVisible.value = true
}
function switchImage(step: number) {
const len = previewImageList.value.length
if (len <= 1) return
currentImageIndex.value = (currentImageIndex.value + step + len) % len
}
const {
code: scanCode,
lastScanned,
handleKeydown
} = useScanInput({
onScan: handleBarscanCode
})
function handleBarscanCode(code: string) {
console.log('handleBarscanCode---code---', code)
lastScanned.value = code
try {
const normalized = code.replace(/'/g, '"').replace(/(\w+)\s*:/g, '"$1":')
const parsed = JSON.parse(normalized)
console.log('handleBarscanCode---parsed---', parsed)
} catch {}
}
const {
code: scanCode2,
lastScanned: lastScanned2,
handleKeydown: handleKeydown2
} = useScanInput({
onScan: handleBarscanCode2
})
function handleBarscanCode2(code: string) {
console.log('handleBarscanCode2---code---', code)
lastScanned2.value = code
}
</script>
<style scoped lang="scss">
@use './index.scss';
</style>
3:关于中文打印,给一个基础的zpl代码实例
let lines = [
'^XA',
'^CI28',
'^PW600',
'^LL600',
'^LH0,0',
'^LS0',
'^FO20,40^A0N,24,24^FDabcd1234^FS',
'^FO20,80^A@N,36,36,E:CSONG.TTF^FH_^FDabcd1234简体中文^FS',
'^XZ'
]
const code = lines.join('\n')
console.log('发送打印指令:', code)
await sendZplToPrinter(selected_device, code, { retries: 1, retryDelayMs: 1000 })
printerStatus.value = '打印任务已发送成功!'
} catch (e: any) {
console.error('打印操作异常:', e)
printerStatus.value = '操作异常:' + (e.message || String(e))
}
sendZplToPrinter函数依旧使用上一步代码中封装的函数
二:实现批量打印
export const zplHexEncodeUtf8 = (text: string) => {
const bytes = new TextEncoder().encode(text)
let out = ''
for (const b of bytes) out += `_${b.toString(16).padStart(2, '0').toUpperCase()}`
return out
}
export const sendZplToPrinter = async (
device: any,
zpl: string,
options?: { retries?: number; retryDelayMs?: number; timeoutMs?: number }
): Promise<void> => {
const retries = Math.max(0, Number(options?.retries ?? 0))
const retryDelayMs = Math.max(0, Number(options?.retryDelayMs ?? 0))
const timeoutMs = Math.max(0, Number(options?.timeoutMs ?? 8000))
const sendOnce = () =>
new Promise<void>((resolve, reject) => {
if (!device || typeof device.send !== 'function') {
reject(new Error('Printer device not available'))
return
}
let done = false
let timer: any
if (timeoutMs > 0) {
timer = setTimeout(() => {
if (done) return
done = true
reject(new Error('Printer send timeout'))
}, timeoutMs)
}
const finish = (err?: any) => {
if (done) return
done = true
if (timer) clearTimeout(timer)
if (err) reject(err instanceof Error ? err : new Error(String(err ?? 'send failed')))
else resolve()
}
device.send(
zpl,
() => finish(),
(err: any) => finish(err)
)
})
for (let attempt = 0; attempt <= retries; attempt++) {
try {
await sendOnce()
return
} catch (e) {
if (attempt >= retries) throw e
if (retryDelayMs > 0) await new Promise((r) => setTimeout(r, retryDelayMs))
}
}
}
export const buildOrderLabelZpl = (options: {
cardNo?: string
userId?: string | number
recognizedInfoString?: string
fontFile?: string
xOffset?: number
widthDots?: number
heightDots?: number
}) => {
const cardNo = String(options.cardNo ?? '').trim()
const userId = String(options.userId ?? '').trim()
const recognizedInfoString = String(options.recognizedInfoString ?? '').trim()
const fontFile = String(options.fontFile ?? '').trim()
const x = Math.max(0, Math.round(Number(options.xOffset ?? 0)))
const widthDots = Math.max(1, Math.round(Number(options.widthDots ?? 480)))
const heightDots = Math.max(1, Math.round(Number(options.heightDots ?? 320)))
const safeAscii = (v: string) =>
v
.replace(/[\x00-\x1F\x7F]/g, ' ')
.replace(/[\^~]/g, ' ')
.trim()
const barcodeData = safeAscii(cardNo)
// const title = '订单标签'
// const titleEncoded = zplHexEncodeUtf8(title)
const recognizedEncoded = zplHexEncodeUtf8(recognizedInfoString) //转hex测试貌似不转也可以
const lines: string[] = ['^XA', '^CI28', `^PW${widthDots}`, `^LL${heightDots}`, '^LH0,0', '^LS0']
if (barcodeData) {
lines.push('^BY2,2,80')
lines.push(`^FO${x},30^BCN,80,Y,N,N^FD${barcodeData}^FS`)
}
//设置标题--可选操作
// if (fontFile) {
// lines.push(`^FO${x},105^A@N,28,28,${fontFile}^FH_^FD${titleEncoded}^FS`)
// } else {
// lines.push(`^FO${x},105^A0N,28,28^FD${title}^FS`)
// }
if (cardNo) lines.push(`^FO${x},140^A0N,24,24^FDCard: ${safeAscii(cardNo)}^FS`)
if (userId) lines.push(`^FO${x},170^A0N,24,24^FDUserId: ${safeAscii(userId)}^FS`)
if (recognizedInfoString) {
const yStart = 200
const lineHeight = 24
const lineGap = 4
const availableHeight = Math.max(0, heightDots - yStart - 10)
const maxLines = Math.max(1, Math.floor(availableHeight / (lineHeight + lineGap)))
const marginRight = 20
const blockWidth = Math.max(1, widthDots - x - marginRight)
if (fontFile) {
lines.push(
`^FO${x},${yStart}^A@N,24,24,${fontFile}^FB${blockWidth},${maxLines},${lineGap},L,0^FH_^FD${recognizedEncoded}^FS`
)
} else {
lines.push(
`^FO${x},${yStart}^A0N,24,24^FB${blockWidth},${maxLines},${lineGap},L,0^FD${safeAscii(recognizedInfoString)}^FS`
)
}
}
lines.push('^XZ')
return lines.join('\n')
}
const normalizeZplFontFile = (fontFile?: string) => {
const raw = String(fontFile ?? '').trim()
if (!raw) return undefined
if (raw.includes(':')) return raw
return `E:${raw}`
}
export const printOrderLabel = async (options: {
device: any
cardNo?: string
userId?: string | number
recognizedInfoString?: string
fontFile?: string
xOffset?: number
widthDots?: number
heightDots?: number
retries?: number
retryDelayMs?: number
timeoutMs?: number
}) => {
const zpl = buildOrderLabelZpl(options)
await sendZplToPrinter(options.device, zpl, {
retries: options.retries,
retryDelayMs: options.retryDelayMs,
timeoutMs: options.timeoutMs
})
return zpl
}
export const printOrderLabelBatch = async (options: {
device: any
items: Array<{ cardNo?: string; userId?: string | number; recognizedInfoString?: string }>
xOffset?: number
widthDots?: number
heightDots?: number
retries?: number
retryDelayMs?: number
timeoutMs?: number
delayBetweenMs?: number
fontFile?: string
}) => {
const device = options.device
const items = Array.isArray(options.items) ? options.items : []
const delayBetweenMs = Math.max(0, Number(options.delayBetweenMs ?? 800))
const fontFile = normalizeZplFontFile(options.fontFile ?? 'SIMSUN.FNT')
const zplList: string[] = []
for (const item of items) {
const zpl = buildOrderLabelZpl({
cardNo: item?.cardNo,
userId: item?.userId,
recognizedInfoString: item?.recognizedInfoString,
fontFile,
xOffset: options.xOffset,
widthDots: options.widthDots,
heightDots: options.heightDots
})
zplList.push(zpl)
}
for (let i = 0; i < zplList.length; i++) {
await sendZplToPrinter(device, zplList[i], {
retries: options.retries,
retryDelayMs: options.retryDelayMs,
timeoutMs: options.timeoutMs
})
if (delayBetweenMs > 0 && i < zplList.length - 1) {
await new Promise((r) => setTimeout(r, delayBetweenMs))
}
}
return zplList
}