Files
shj-app/scanpages/components/nbd-print-prop-qr.vue
T
2026-08-18 18:05:16 +08:00

749 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<u-popup v-model="localVisible" mode="bottom" :height="height" :closeable="true" @close="handleClose">
<view class="print-popup">
<!-- 标题栏 -->
<view class="print-popup__header">
<text class="print-popup__title">标签打印</text>
</view>
<!-- 隐藏画布:标签绘制 -->
<canvas
:id="canvasId"
:canvas-id="canvasId"
type="2d"
:style="canvasStyles"
/>
<!-- 隐藏画布:二维码生成(独立,避免占用标签画布导致只导出左上角) -->
<canvas
:id="barcodeCanvasId"
:canvas-id="barcodeCanvasId"
type="2d"
:style="barcodeCanvasStyles"
/>
<scroll-view scroll-y="true" class="print-popup__body">
<!-- 预览区域 -->
<view class="preview-section" v-if="previewList.length > 0">
<text class="preview-section__title">打印预览</text>
<view class="preview-section__list">
<image
v-for="item in previewList"
:src="item.value"
:key="item.key"
class="preview-img"
mode="widthFix"
/>
</view>
</view>
<!-- 打印机选择 -->
<view class="setting-row">
<text class="setting-row__label">打印机</text>
<view class="setting-row__right">
<picker :value="deviceIndex" :range="deviceList" @change="onDeviceChanged" range-key="name">
<view class="setting-row__picker">
<text
class="setting-row__value"
:class="{ 'setting-row__value--empty': !deviceList[deviceIndex]?.deviceId }"
>
{{ deviceList[deviceIndex]?.name || '未检测到打印机' }}
</text>
<u-icon name="arrow-down" :size="24" color="#999" />
</view>
</picker>
</view>
</view>
<!-- 出纸方向 -->
<view class="setting-row">
<text class="setting-row__label">出纸方向</text>
<view class="setting-row__right">
<picker :value="rotationIndex" :range="rotationList" @change="onRotationChanged" range-key="name">
<view class="setting-row__picker">
<text class="setting-row__value">{{ rotationList[rotationIndex]?.name || '' }}</text>
<u-icon name="arrow-down" :size="24" color="#999" />
</view>
</picker>
</view>
</view>
<!-- 纸张类型 -->
<view class="setting-row">
<text class="setting-row__label">纸张类型</text>
<view class="setting-row__right">
<picker :value="gapIndex" :range="gapList" @change="onGapTypeChanged" range-key="name">
<view class="setting-row__picker">
<text class="setting-row__value">{{ gapList[gapIndex]?.name || '' }}</text>
<u-icon name="arrow-down" :size="24" color="#999" />
</view>
</picker>
</view>
</view>
<!-- 打印浓度 -->
<view class="setting-row">
<text class="setting-row__label">打印浓度</text>
<view class="setting-row__right">
<picker :value="darknessIndex" :range="darknessList" @change="onDarknessChanged" range-key="name">
<view class="setting-row__picker">
<text class="setting-row__value">{{ darknessList[darknessIndex]?.name || '' }}</text>
<u-icon name="arrow-down" :size="24" color="#999" />
</view>
</picker>
</view>
</view>
<!-- 打印速度 -->
<view class="setting-row">
<text class="setting-row__label">打印速度</text>
<view class="setting-row__right">
<picker :value="speedIndex" :range="speedList" @change="onPrintSpeedChanged" range-key="name">
<view class="setting-row__picker">
<text class="setting-row__value">{{ speedList[speedIndex]?.name || '' }}</text>
<u-icon name="arrow-down" :size="24" color="#999" />
</view>
</picker>
</view>
</view>
<!-- 灰度阈值 -->
<view class="setting-row">
<text class="setting-row__label">灰度阈值</text>
<view class="setting-row__right">
<input class="setting-row__input" type="number" v-model="threshold" />
</view>
</view>
<!-- JSON配置模式 -->
<view class="setting-row">
<text class="setting-row__label">JSON配置</text>
<view class="setting-row__right">
<switch :checked="jsonMode" @change="onPrintModeChanged" style="transform: scale(0.75)" />
</view>
</view>
</scroll-view>
<!-- 底部操作按钮 -->
<view class="print-popup__footer">
<view class="footer-grid">
<view class="footer-btn" @click="handleStartDiscovery">
<u-icon name="search" :size="30" color="#2979ff" />
<text>搜索打印机</text>
</view>
<view class="footer-btn" @click="handleStopDiscovery">
<u-icon name="close-circle" :size="30" color="#f56c6c" />
<text>停止搜索</text>
</view>
<view class="footer-btn" @click="handleOpenPrinter">
<u-icon name="link" :size="30" color="#2979ff" />
<text>连接打印机</text>
</view>
<view class="footer-btn" @click="handleClosePrinter">
<u-icon name="minus-circle" :size="30" color="#f56c6c" />
<text>断开连接</text>
</view>
<view class="footer-btn footer-btn--print" @click="handlePrintLabel">
<u-icon v-if="!printing" name="printer" :size="30" color="#fff" />
<u-icon v-else name="reload" :size="30" color="#fff" class="spinning" />
<text>{{ printing ? '打印中...' : '打印' }}</text>
</view>
</view>
</view>
</view>
</u-popup>
</view>
</template>
<script setup>
import { ref, watch, onBeforeUnmount, nextTick, getCurrentInstance } from 'vue'
import {
DrawType,
LPAPIFactory
} from '@/assets/js/js_sdk'
import '@/utils/bwipjs-polyfill.js'
import bwipjs from '@/scanpages/assets/bwip-js/bwip-js.mjs'
// ===== Props =====
const props = defineProps({
modelValue: { type: Boolean, default: false },
height: { type: String, default: '85%' },
labelWidth: { type: Number, default: 50 },
labelHeight: { type: Number, default: 30 },
margin: { type: Number, default: 2 },
barcodeText: { type: String, default: '12345678' },
barcodeTopText: { type: String, default: '' },
barcodeBottomText: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'close', 'printed', 'error'])
const instance = getCurrentInstance()
// ===== 响应式数据 =====
const canvasId = 'lpapi-ble-popup-qr'
const barcodeCanvasId = 'lpapi-ble-barcode-qr'
const labelW = ref(960)
const labelH = ref(960)
const barcodeCanvasPx = ref(320)
const canvasStyles = ref({})
const barcodeCanvasStyles = ref({})
const localVisible = ref(false)
const deviceList = ref([{ name: '未检测到打印机' }])
const deviceIndex = ref(0)
const rotationIndex = ref(1)
const rotationList = [
{ name: '横向打印', value: 0 },
{ name: '右转90度', value: 90 },
{ name: '转转180度', value: 180 },
{ name: '左转90度', value: 270 }
]
const gapList = [
{ name: '随打印机设置', value: 255 },
{ name: '小票纸', value: 0 },
{ name: '不干胶', value: 2 },
{ name: '卡纸', value: 3 }
]
const gapIndex = ref(0)
const darknessList = [
{ name: '随打印机设置', value: 255 },
{ name: '6 (正常)', value: 6 },
{ name: '7', value: 7 },
{ name: '8', value: 8 },
{ name: '9', value: 9 },
{ name: '10 (较浓)', value: 10 },
{ name: '11', value: 11 },
{ name: '12', value: 12 },
{ name: '13', value: 13 },
{ name: '14', value: 14 },
{ name: '15 (最浓)', value: 15 }
]
const darknessIndex = ref(0)
const speedList = [
{ name: '随打印机设置', value: 255 },
{ name: '最慢', value: 1 },
{ name: '较慢', value: 2 },
{ name: '正常', value: 3 },
{ name: '较快', value: 4 },
{ name: '最快', value: 5 }
]
const speedIndex = ref(0)
const threshold = ref(128)
const jsonMode = ref(false)
const printing = ref(false)
const isWeiXin = ref(false)
const isAlipay = ref(false)
const lpapi = ref(undefined)
const context = ref(undefined)
const previewList = ref([])
// ===== 取值辅助 =====
const getDevice = () => {
return deviceList.value[deviceIndex.value]
}
const getOrientation = () => {
return rotationList[rotationIndex.value]?.value || 0
}
const getGapType = () => {
return gapList[gapIndex.value]?.value || 255
}
const getPrintDarkness = () => {
return darknessList[darknessIndex.value]?.value || 255
}
const getPrintSpeed = () => {
return speedList[speedIndex.value]?.value || 255
}
const getThreshold = () => {
return Number(threshold.value)
}
// ===== Canvas 样式 =====
const updateCanvasStyle = () => {
var pos = { position: 'absolute' }
if (isWeiXin.value) {
pos.left = '-999999rpx'
pos.top = '-999999rpx'
pos.opacity = '0'
pos.pointerEvents = 'none'
pos.zIndex = '-1'
} else {
pos.left = '-999999rpx'
pos.top = '-999999rpx'
}
canvasStyles.value = Object.assign(
{ width: labelW.value + 'px', height: labelH.value + 'px' },
pos
)
barcodeCanvasStyles.value = Object.assign(
{
width: barcodeCanvasPx.value + 'px',
height: barcodeCanvasPx.value + 'px',
},
pos
)
}
// ===== Canvas 更新 =====
const updateCanvas = (jobInfo) => {
return new Promise((resolve) => {
if (jobInfo && jobInfo.canvas) {
labelW.value = jobInfo.canvas.width
labelH.value = jobInfo.canvas.height
updateCanvasStyle()
}
setTimeout(() => resolve(true), 100)
})
}
// ===== 二维码生成(bwip-js qrcode,保持正方形不拉伸)=====
const _renderBwipjs = (canvas, opts) => {
try {
bwipjs.toCanvas(canvas, opts)
} catch (e) {
if (typeof canvas.getContext === 'function') {
var proto = Object.getPrototypeOf(canvas)
var ctorName = proto && proto.constructor && proto.constructor.name
if (ctorName && !(/HTMLCanvasElement|OffscreenCanvas/).test(ctorName)) {
try {
Object.defineProperty(proto.constructor, 'name', { configurable: true, value: 'HTMLCanvasElement' })
bwipjs.toCanvas(canvas, opts)
Object.defineProperty(proto.constructor, 'name', { configurable: true, value: ctorName })
return
} catch (e2) {}
}
}
throw e
}
}
/**
* 生成正方形二维码图片(使用独立画布,避免与标签画布尺寸冲突)
* @param {string} text
* @param {number} sizeMM 边长(毫米)
*/
const generateBarcodeImage = (text, sizeMM) => {
return new Promise((resolve, reject) => {
var dpi = 203
var pxPerMM = dpi / 25.4
var tw = Math.max(160, Math.round(sizeMM * pxPerMM))
barcodeCanvasPx.value = tw
updateCanvasStyle()
var baseScale = Math.max(4, Math.floor(tw / 33))
var bwipOpts = {
bcid: 'qrcode',
text: text || '',
scale: baseScale,
includetext: false,
padding: 2
}
nextTick(() => {
setTimeout(() => {
var query = uni.createSelectorQuery().in(instance)
query.select('#' + barcodeCanvasId).node().exec((res) => {
try {
if (!res || !res[0] || !res[0].node) {
return reject(new Error('二维码 canvas 未找到'))
}
var canvas = res[0].node
// 先固定位图尺寸,再交给 bwip 绘制(微信 2d canvas 不独立设置会沿用错误尺寸)
canvas.width = tw
canvas.height = tw
_renderBwipjs(canvas, bwipOpts)
var outW = canvas.width || tw
var outH = canvas.height || tw
uni.canvasToTempFilePath({
canvas: canvas,
x: 0,
y: 0,
width: outW,
height: outH,
destWidth: outW,
destHeight: outH,
success: (t) => {
if (isWeiXin.value) {
var img = canvas.createImage()
img.src = t.tempFilePath
img.onload = () => { resolve(img) }
img.onerror = (e) => { reject(e) }
} else {
resolve(t.tempFilePath)
}
},
fail: (e) => { reject(e) }
})
} catch (e) { reject(e) }
})
}, 50)
})
})
}
/**
* 在可用区域内居中放置正方形二维码
* @param {number} areaX
* @param {number} areaY
* @param {number} areaW
* @param {number} areaH
*/
const getQrDrawRect = (areaX, areaY, areaW, areaH) => {
var size = Math.min(areaW, areaH)
return {
x: areaX + (areaW - size) / 2,
y: areaY + (areaH - size) / 2,
size
}
}
// ===== 标签绘制(总入口) =====
const printLabel = (isPreview) => {
if (jsonMode.value) {
return printLabelWithJsonMode(isPreview)
} else {
return printLabelWithDrawMode(isPreview)
}
}
const printLabelWithDrawMode = async (isPreview) => {
var api = lpapi.value
var lW = props.labelWidth
var lH = props.labelHeight
var m = props.margin
var uW = lW - m * 2
var jobInfo = api.startJob({
context: context.value,
width: lW,
height: lH,
orientation: getOrientation(),
isPreview: isPreview
})
await updateCanvas(jobInfo)
if (props.barcodeTopText) {
api.drawText({
text: props.barcodeTopText,
x: m,
y: m,
width: uW,
height: 5,
fontHeight: 4,
horizontalAlignment: 1
})
}
var barcodeY = m + (props.barcodeTopText ? 7 : 0)
var barcodeH = lH - m * 2 - (props.barcodeTopText ? 7 : 0) - (props.barcodeBottomText ? 7 : 0)
try {
var rect = getQrDrawRect(m, barcodeY, uW, barcodeH)
var img = await generateBarcodeImage(props.barcodeText, rect.size)
api.drawImage({ img: img, x: rect.x, y: rect.y, width: rect.size, height: rect.size })
} catch (e) {
console.error('二维码绘制失败:', e)
}
if (props.barcodeBottomText) {
var by = lH - m - 5
api.drawText({
text: props.barcodeBottomText,
x: m,
y: by,
width: uW,
height: 5,
fontHeight: 3.5,
horizontalAlignment: 1
})
}
return api.commitJob({
gapType: getGapType(),
printDarkness: getPrintDarkness(),
printSpeed: getPrintSpeed()
})
}
const printLabelWithJsonMode = (isPreview) => {
var device = getDevice()
var lW = props.labelWidth
var lH = props.labelHeight
var m = props.margin
var uW = lW - m * 2
var jobPage = []
if (props.barcodeTopText) {
jobPage.push({
type: DrawType.text,
text: props.barcodeTopText,
x: m,
y: m,
width: uW,
height: 5,
fontHeight: 4,
horizontalAlignment: 1
})
}
var barcodeY = m + (props.barcodeTopText ? 7 : 0)
var barcodeH = lH - m * 2 - (props.barcodeTopText ? 7 : 0) - (props.barcodeBottomText ? 7 : 0)
var rect = getQrDrawRect(m, barcodeY, uW, barcodeH)
return generateBarcodeImage(props.barcodeText, rect.size).then((img) => {
jobPage.push({
type: DrawType.image,
img: img,
x: rect.x,
y: rect.y,
width: rect.size,
height: rect.size
})
if (props.barcodeBottomText) {
jobPage.push({
type: DrawType.text,
text: props.barcodeBottomText,
x: m,
y: lH - m - 5,
width: uW,
height: 5,
fontHeight: 3.5,
horizontalAlignment: 1
})
}
return lpapi.value.print({
jobInfo: {
jobWidth: lW,
jobHeight: lH,
orientation: getOrientation(),
isPreview: isPreview
},
printerInfo: {
name: device ? device.name : undefined,
deviceId: device ? device.deviceId : undefined
},
jobPage: jobPage,
onJobCreated: (jobInfo) => {
return updateCanvas(jobInfo)
},
onPageComplete: (res) => {
// 标签页面渲染完毕时的回调
},
onPagePrintComplete: (res) => {
// 标签页面打印完毕时的回调
}
})
})
}
// ===== 初始化 =====
const initPrinter = () => {
// #ifdef MP-WEIXIN
isWeiXin.value = true
// #endif
// #ifdef MP-ALIPAY
isAlipay.value = true
// #endif
updateCanvasStyle()
lpapi.value = LPAPIFactory.getInstance({ showLog: 4 })
lpapi.value.startDiscovery({
timeout: 0,
deviceFound: (devices) => {
if (devices && devices.length > 0) {
deviceList.value.splice(0)
deviceList.value.push(...devices)
}
}
})
if (isWeiXin.value) {
context.value = lpapi.value.createDrawContext()
} else {
context.value = lpapi.value.createDrawContext({ canvasId: canvasId })
}
if (context.value) {
lpapi.value.setDrawContext(context.value)
handlePreviewLabel()
}
}
// ===== 选择器变更 =====
const onDeviceChanged = (e) => {
deviceIndex.value = e ? e.detail.value : 0
}
const onRotationChanged = (e) => {
rotationIndex.value = e ? e.detail.value : 0
}
const onGapTypeChanged = (e) => {
gapIndex.value = e ? e.detail.value : 0
}
const onDarknessChanged = (e) => {
darknessIndex.value = e ? e.detail.value : 0
}
const onPrintSpeedChanged = (e) => {
speedIndex.value = e ? e.detail.value : 0
}
const onPrintModeChanged = (e) => {
jsonMode.value = e.detail.value
handlePreviewLabel()
}
// ===== 打印机操作 =====
const handleStartDiscovery = () => {
uni.showLoading({ title: '正在搜索打印机...' })
lpapi.value.startDiscovery({
timeout: 5000,
deviceFound: (devices) => {
if (devices && devices.length > 0) {
deviceList.value.splice(0)
deviceList.value.push(...devices)
}
},
adapterStateChange: (result) => {
if (!result.discovering) {
uni.hideLoading()
}
}
})
}
const handleStopDiscovery = () => {
lpapi.value.stopDiscovery()
uni.hideLoading()
}
const handleOpenPrinter = () => {
const currDevice = getDevice()
if (!currDevice || !currDevice.deviceId) {
uni.showToast({ title: '未检测到打印机', icon: 'none' })
return
}
uni.showLoading({ title: '正在连接打印机...' })
lpapi.value.openPrinter({
name: currDevice.name,
deviceId: currDevice.deviceId,
tryTimes: isAlipay.value ? 1 : 5,
success: (resp) => {
console.log('---- 【打印机连接成功】')
uni.hideLoading()
uni.showToast({ title: '打印机连接成功!', icon: 'success' })
},
fail: (resp) => {
console.warn('---- 【打印机连接失败】:', JSON.stringify(resp))
uni.hideLoading()
uni.showToast({ title: '打印机连接失败!', icon: 'error' })
}
})
}
const handleClosePrinter = () => {
console.log('---- 关闭打印机!')
lpapi.value.closePrinter()
}
// ===== 预览 =====
const handlePreviewLabel = () => {
previewList.value.splice(0)
printLabel(true).then((res) => {
if (res.statusCode === 0) {
res.previewData.forEach((v) => {
previewList.value.push({
value: v,
key: Date.now() + '-' + Math.random() * 1000
})
})
} else {
console.warn('预览失败:', res)
}
})
}
// ===== 打印 =====
const handlePrintLabel = async () => {
printing.value = true
try {
var d = getDevice()
if (!d || !d.deviceId) {
printing.value = false
return uni.showToast({ title: '未检测到打印机', icon: 'none' })
}
await new Promise((resolve, reject) => {
lpapi.value.openPrinter({
name: d.name,
deviceId: d.deviceId,
tryTimes: 5,
success: resolve,
fail: reject
})
})
var res = await printLabel(false)
if (res.statusCode === 0) {
uni.showToast({ title: '打印成功!', icon: 'success' })
emit('printed', { statusCode: 0 })
} else {
uni.showToast({ title: res.errMsg || '打印失败!', icon: 'error' })
emit('error', res)
}
} catch (e) {
console.error('打印异常:', e)
uni.showToast({ title: '打印机连接失败!', icon: 'error' })
emit('error', e)
} finally {
printing.value = false
}
}
// ===== 关闭弹窗 =====
const handleClose = () => {
handleClosePrinter()
emit('close')
}
// ===== 监听器 =====
watch(() => props.modelValue, (val) => {
localVisible.value = val
if (val) nextTick(() => initPrinter())
})
watch(localVisible, (val) => {
emit('update:modelValue', val)
})
onBeforeUnmount(() => {
if (lpapi.value) {
lpapi.value.closePrinter()
lpapi.value.stopDiscovery()
}
})
</script>
<style lang="scss" scoped>
.print-popup { display: flex; flex-direction: column; height: 100%; background: #f5f6f8;
&__header { flex-shrink: 0; padding: 28rpx 32rpx 20rpx; text-align: center; border-bottom: 1rpx solid #e8ecf1; }
&__title { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
&__body { flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box; padding: 20rpx 24rpx; }
&__footer { flex-shrink: 0; padding: 12rpx 24rpx; padding-bottom: calc(12rpx + env(safe-area-inset-bottom)); background: #fff; border-top: 1rpx solid #e8ecf1; }
}
.footer-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12rpx; }
.footer-btn { display: flex; align-items: center; justify-content: center; gap: 8rpx; height: 76rpx; font-size: 26rpx; font-weight: 500; border-radius: 12rpx; background: #f5f6f8; color: #1a1a2e; transition: transform .15s, box-shadow .15s;
&:active { transform: scale(.95); opacity: .85; }
&--print { background: linear-gradient(135deg, #2979ff, #5a9aff); color: #fff; box-shadow: 0 4rpx 16rpx rgba(41,121,255,.35); grid-column: 1 / -1;}
}
.spinning { animation: spin .8s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.setting-row { display: flex; align-items: center; justify-content: space-between; box-sizing: border-box; padding: 22rpx 24rpx; margin-bottom: 12rpx; background: #fff; border-radius: 12rpx;
&__label { font-size: 26rpx; color: #6b7c93; flex-shrink: 0; width: 160rpx; }
&__right { flex: 1; display: flex; justify-content: flex-end; }
&__picker { display: flex; align-items: center; gap: 8rpx; }
&__value { font-size: 26rpx; color: #1a1a2e; font-weight: 500;
&--empty { color: #b0bec5; }
}
&__input { flex: 1; font-size: 26rpx; color: #1a1a2e; text-align: right; padding: 4rpx 0; }
}
.preview-section { margin-bottom: 24rpx; padding: 20rpx; background: #fff; border-radius: 12rpx; overflow: hidden;
&__title { display: block; font-size: 26rpx; font-weight: 600; color: #1a1a2e; margin-bottom: 16rpx; }
&__list { display: flex; flex-wrap: wrap; justify-content: center; gap: 16rpx; }
}
.preview-img { width: 100%; border: 1rpx dashed #ccc; border-radius: 4rpx; }
</style>