zebra打印机实现前端打印

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
}
相关推荐
摇滚侠2 小时前
前端判断不等于 undefined 不等于 null 的方法
前端
DFT计算杂谈2 小时前
VASP+Wannier90 计算位移电流和二次谐波SHG
java·服务器·前端·python·算法
zhougl9962 小时前
Vue 中使用 WebSocket
前端·vue.js·websocket
无名的小白2 小时前
openclaw使用nginx反代部署过程 与disconnected (1008): pairing required解决
java·前端·nginx
2601_949857432 小时前
Flutter for OpenHarmony Web开发助手App实战:文本统计
前端·flutter
光影少年2 小时前
智能体UI ux pro max
前端·ui·ux
半梅芒果干2 小时前
vue3 实现无缝循环滚动
前端·javascript·vue.js
qq_419854052 小时前
锚点跳转及鼠标滚动与锚点高亮联动
前端
冰敷逆向2 小时前
京东h5st纯算分析
java·前端·javascript·爬虫·安全·web