Files
2026-06-27 20:58:16 +08:00

815 lines
21 KiB
Vue

<template>
<view class="container">
<!-- 状态栏 -->
<view class="status-section">
<view class="status-item">
<text class="status-label">蓝牙适配器</text>
<text class="status-value" :class="adapterState.available ? 'available' : ''">
{{ adapterState.available ? '就绪' : '未就绪' }}
</text>
</view>
<view class="status-item">
<text class="status-label">打印机</text>
<text class="status-value" :class="{ connected: connected }">
{{ connected ? '已连接' : '未连接' }}
</text>
</view>
<view class="status-item">
<text class="status-label">连接方式</text>
<text class="status-value auto">自动</text>
</view>
</view>
<!-- 连接信息 -->
<view class="section" v-if="savedConfig">
<view class="section-header">
<text class="section-title">已保存的配置</text>
</view>
<view class="config-info">
<view class="config-row">
<text class="config-label">设备名称</text>
<text class="config-value">{{ savedConfig.deviceName }}</text>
</view>
<view class="config-row">
<text class="config-label">服务UUID</text>
<text class="config-value uuid">{{ savedConfig.serviceId }}</text>
</view>
<view class="config-row">
<text class="config-label">特征值UUID</text>
<text class="config-value uuid">{{ savedConfig.writeCharId }}</text>
</view>
<view class="config-row" v-if="savedConfig.paperWidth && savedConfig.paperHeight">
<text class="config-label">纸尺寸</text>
<text class="config-value">{{ savedConfig.paperWidth }}mm x {{ savedConfig.paperHeight }}mm</text>
</view>
<view class="config-row" v-else-if="savedConfig.paperSize">
<text class="config-label">纸尺寸</text>
<text class="config-value">{{ savedConfig.paperSize }}</text>
</view>
<view class="config-row" v-if="savedConfig.lastUpdated">
<text class="config-label">保存时间</text>
<text class="config-value">{{ formatDate(savedConfig.lastUpdated) }}</text>
</view>
</view>
<view class="config-actions">
<button type="warn" size="mini" @click="clearConfig">清除配置</button>
</view>
</view>
<!-- 无配置提示 -->
<view class="section empty-section" v-else>
<text class="empty-text">暂无保存的配置</text>
<text class="empty-hint">请先在蓝牙打印页面连接打印机并保存配置</text>
<button type="primary" size="mini" @click="goToBlePrint">前往配置</button>
</view>
<!-- 打印控制区 -->
<view class="section print-section" v-if="connected">
<view class="section-header">
<text class="section-title">打印控制</text>
<text class="printer-name">{{ connectedDeviceName }}</text>
</view>
<!-- 指令类型选择 -->
<view class="command-type">
<view class="type-label">指令类型:</view>
<view class="type-switch">
<view
class="type-btn"
:class="{ active: commandType === 'CPCL' }"
@click="commandType = 'CPCL'">
CPCL
</view>
<view
class="type-btn"
:class="{ active: commandType === 'TSPL' }"
@click="commandType = 'TSPL'">
TSPL
</view>
</view>
</view>
<!-- 纸尺寸选择 -->
<view class="paper-size-section">
<text class="paper-size-label">打印纸尺寸</text>
<view class="paper-size-grid">
<view
class="paper-size-item"
:class="{ selected: paperWidth === 100 && paperHeight === 100 }"
@click="selectPaperSize(100, 100)">
<text class="paper-size-name">100mmx100mm</text>
</view>
<view
class="paper-size-item"
:class="{ selected: paperWidth === 150 && paperHeight === 150 }"
@click="selectPaperSize(150, 150)">
<text class="paper-size-name">150mmx150mm</text>
</view>
<view
class="paper-size-item"
:class="{ selected: paperWidth === 200 && paperHeight === 200 }"
@click="selectPaperSize(200, 200)">
<text class="paper-size-name">200mmx200mm</text>
</view>
</view>
</view>
<!-- 打印内容 -->
<view class="print-content">
<view class="content-header">
<text class="content-title">打印内容</text>
<view class="content-actions">
<button size="mini" @click="clearContent">清空</button>
<button size="mini" type="primary" @click="doPrint">打印</button>
</view>
</view>
<textarea
class="content-textarea"
v-model="printContent"
:placeholder="contentPlaceholder">
</textarea>
</view>
<!-- 快捷模板 -->
<view class="template-section">
<text class="template-title">快捷模板</text>
<scroll-view class="template-list" scroll-x>
<view class="template-tags">
<view class="template-tag" @click="useTemplate('qrcode')">二维码</view>
<view class="template-tag" @click="useTemplate('barcode')">条码</view>
<view class="template-tag" @click="useTemplate('label')">标签</view>
</view>
</scroll-view>
</view>
</view>
<!-- 连接中状态 -->
<view class="section connecting-section" v-else-if="savedConfig && isConnecting">
<view class="connecting-content">
<view class="loading-icon"></view>
<text class="connecting-text">正在自动连接...</text>
<text class="connecting-hint">{{ savedConfig.deviceName }}</text>
</view>
<button type="default" size="mini" @click="cancelConnect">取消</button>
</view>
</view>
</template>
<script>
import * as iconv from 'iconv-lite';
import { mapGetters, mapActions } from 'vuex';
export default {
data() {
return {
adapterState: {
available: false,
discovering: false
},
isConnecting: false,
connectedDeviceId: '',
connectedDeviceName: '',
connected: false,
writeServiceId: '',
writeCharacteristicId: '',
commandType: 'CPCL',
printContent: ''
}
},
computed: {
// 从 Vuex store 映射配置
...mapGetters('print', ['savedConfig', 'paperWidth', 'paperHeight']),
contentPlaceholder() {
const sizeCmd = this.commandType === 'CPCL'
? `SIZE ${this.paperWidth}mm ${this.paperHeight}mm`
: `SIZE ${this.paperWidth} mm ${this.paperHeight} mm`
const placeholders = {
'TSPL': `输入TSPL指令,如:\n${sizeCmd}\nGAP 2 mm\nCLS\nTEXT 50 50 "4" 0 1 1 "Hello"\nPRINT`,
'CPCL': `输入CPCL指令,如:\n! 0 200 200 240 1\n${sizeCmd}\nGAP 2mm\nTEXT 4 0 10 10 EN Hello\nPRINT`
}
return placeholders[this.commandType]
}
},
onLoad() {
this.loadConfig()
},
onUnload() {
this.cleanup()
},
methods: {
// 映射 Vuex actions
...mapActions('print', ['updatePaperSize', 'clearPrinterConfig']),
formatDate(dateStr) {
const date = new Date(dateStr)
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
},
loadConfig() {
// 配置通过 mapGetters 从 store 获取,自动同步
// 如果有保存的配置,自动尝试连接
if (this.savedConfig?.deviceId) {
setTimeout(() => {
this.autoConnect()
}, 500)
}
},
async clearConfig() {
try {
await this.clearPrinterConfig()
uni.showToast({ title: '配置已清除', icon: 'success' })
} catch (error) {
console.error('清除配置失败:', error)
}
},
goToBlePrint() {
uni.navigateTo({
url: '/pages/testpages/bleprint'
})
},
async autoConnect() {
if (!this.savedConfig) return
this.isConnecting = true
try {
await this.initBluetooth()
await this.createConnection()
this.connected = true
this.connectedDeviceId = this.savedConfig.deviceId
this.connectedDeviceName = this.savedConfig.deviceName
this.writeServiceId = this.savedConfig.serviceId
this.writeCharacteristicId = this.savedConfig.writeCharId
this.isConnecting = false
uni.showToast({ title: '连接成功', icon: 'none' })
} catch (err) {
this.isConnecting = false
uni.showToast({ title: '连接失败', icon: 'none' })
}
},
initBluetooth() {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success: () => {
this.adapterState.available = true
uni.onBLEConnectionStateChange((res) => {
if (!res.connected && this.connectedDeviceId === res.deviceId) {
this.connected = false
this.connectedDeviceId = ''
this.connectedDeviceName = ''
}
})
resolve()
},
fail: (err) => {
this.adapterState.available = false
// 检测蓝牙是否未打开
if (err.errMsg && (err.errMsg.includes('not available') || err.errMsg.includes('available'))) {
uni.showModal({
title: '蓝牙未开启',
content: '请先开启手机蓝牙,然后重试',
confirmText: '重试',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
// 延迟重试
setTimeout(() => {
this.autoConnect()
}, 300)
}
}
})
}
reject(err)
}
})
})
},
createConnection() {
return new Promise((resolve, reject) => {
uni.createBLEConnection({
deviceId: this.savedConfig.deviceId,
timeout: 15000,
success: () => {
resolve()
},
fail: (err) => {
reject(err)
}
})
})
},
cancelConnect() {
this.isConnecting = false
},
disconnectPrinter() {
if (!this.connectedDeviceId) return
uni.closeBLEConnection({
deviceId: this.connectedDeviceId,
success: () => {
this.connected = false
this.connectedDeviceId = ''
this.connectedDeviceName = ''
}
})
},
clearContent() {
this.printContent = ''
},
/**
* 选择打印纸尺寸
* @param {number} width - 纸张宽度(mm)
* @param {number} height - 纸张高度(mm)
*/
async selectPaperSize(width, height) {
try {
await this.updatePaperSize({ width, height })
uni.showToast({
title: `已选择 ${width}mm x ${height}mm`,
icon: 'none',
duration: 1500
})
} catch (error) {
console.error('更新纸张尺寸失败:', error)
}
},
// 获取当前配置的纸张尺寸对应的SIZE命令
getSizeCommand() {
const { paperWidth, paperHeight, commandType } = this
if (commandType === 'CPCL') {
return `SIZE ${paperWidth}mm ${paperHeight}mm`
} else if (commandType === 'TSPL') {
return `SIZE ${paperWidth} mm ${paperHeight} mm`
}
return ''
},
useTemplate(type) {
// 如果是标签类型,使用废物标签模板
if (type === 'label') {
const wasteData = {
mingcheng: '废弃电池',
leibie: '电子废物',
daima: 'HW01',
xingtai: '固体',
zhuyaochengfen: '锂、钴',
youhaichengfen: '重金属',
zhuyi: '防潮防火',
shibiema: '2025-001',
danwei: '环保公司',
lianxi: '13800000000',
riqi: '2025-12-31',
zhongliang: '5.2KG',
beizhu: '需专业处理',
biaoshi: '●',
// qrcode: 'http://www.opmonitor.com/?hw=7G6MOYELnFHZfF1Y0F8e6gjxR2K2pALN'
qrcode: 'https://www.cswm.org.cn/qr/?code=7G6MOYELnFHZfF1Y0F8e6gjxR2K2pALNJJNhJSuOg17LV6IZfuDWUnF2qC3Rsdejs&type=WFBQ'
}
this.useWasteLabel(wasteData)
return
}
const sizeCmd = this.getSizeCommand()
const gapCmd = this.commandType === 'CPCL' ? 'GAP 2mm' : 'GAP 2 mm'
const templates = {
qrcode: {
CPCL: `! 0 200 200 240 1\n${sizeCmd}\n${gapCmd}\nCENTER\nQR 300 10 M 2 A 0\nMA,https://www.example.com\nENDQR\nPRINT`,
TSPL: `${sizeCmd}\n${gapCmd}\nCLS\nQRCODE 50 50 L 5 0 A 0 "https://www.example.com"\nPRINT`
},
barcode: {
CPCL: `! 0 200 200 240 1\n${sizeCmd}\n${gapCmd}\nCENTER\nBARCODE 128 1 1 60 1 0 10 40 1234567890\nPRINT`,
TSPL: `${sizeCmd}\n${gapCmd}\nCLS\nBARCODE 50 50 "128" 50 1 0 2 2 "1234567890"\nPRINT`
}
}
this.printContent = templates[type][this.commandType]
},
stringToBuffer(str) {
try {
const uint8Array = iconv.encode(str, 'gbk');
return uint8Array.buffer;
} catch (e) {
console.warn('编码失败,使用回退方案', e)
const buffer = new ArrayBuffer(str.length)
const dataView = new DataView(buffer)
for (let i = 0; i < str.length; i++) {
dataView.setUint8(i, str.charCodeAt(i) & 0xFF)
}
return buffer
}
},
doPrint() {
if (!this.connected) {
uni.showToast({ title: '请先连接打印机', icon: 'none' })
return
}
if (!this.printContent.trim()) {
uni.showToast({ title: '请输入打印内容', icon: 'none' })
return
}
if (!this.writeCharacteristicId) {
uni.showToast({ title: '未配置特征值', icon: 'none' })
return
}
const buffer = this.stringToBuffer(this.printContent)
uni.writeBLECharacteristicValue({
deviceId: this.connectedDeviceId,
serviceId: this.writeServiceId,
characteristicId: this.writeCharacteristicId,
value: buffer,
writeType: 'writeNoResponse',
success: () => {
uni.showToast({ title: '打印成功', icon: 'none' })
},
fail: (err) => {
console.log(err)
uni.showToast({ title: '发送失败', icon: 'error' })
}
})
},
generateWasteLabel(data) {
const sizeCmd = this.getSizeCommand()
const gapCmd = this.commandType === 'CPCL' ? 'GAP 2mm' : 'GAP 2 mm'
if (this.commandType === 'CPCL') {
return this.generateCPCLWasteLabel(data, sizeCmd, gapCmd)
} else {
return this.generateTSPLWasteLabel(data, sizeCmd, gapCmd)
}
},
generateCPCLWasteLabel(data, sizeCmd, gapCmd) {
const fieldOrder = [
'mingcheng', 'leibie', 'daima', 'xingtai',
'zhuyaochengfen', 'youhaichengfen', 'zhuyi',
'shibiema', 'danwei',
'lianxi', 'riqi',
'zhongliang', 'beizhu', 'biaoshi'
]
const fieldLabels = {
mingcheng: '名称', leibie: '类别', daima: '代码', xingtai: '形态',
zhuyaochengfen: '主要成分', youhaichengfen: '有害成分', zhuyi: '注意',
shibiema: '识别码', danwei: '单位', lianxi: '联系', riqi: '日期',
zhongliang: '重量', beizhu: '备注', biaoshi: '标识'
}
const conf = [
{x: 192, y: 100, L: '24mm', T: 'mm', label: '名称', field: 'mingcheng'},
{x: 192, y: 150, L: '24mm', T: 'mm', label: '类别', field: 'leibie'},
{x: 192, y: 200, L: '24mm', T: 'mm', label: '代码', field: 'daima'},
{x: 480, y: 200, L: '60mm', T: 'mm', label: '形态', field: 'xingtai'},
{x: 192, y: 250, L: '24mm', T: 'mm', label: '主要成分', field: 'zhuyaochengfen'},
{x: 192, y: 350, L: '24mm', T: 'mm', label: '有害成分', field: 'youhaichengfen'},
{x: 192, y: 448, L: '24mm', T: '56mm', label: '注意', field: 'zhuyi'},
{x: 216, y: 536, L: '27mm', T: '67mm', label: '识别码', field: 'shibiema'},
{x: 256, y: 584, L: '32mm', T: '73mm', label: '单位', field: 'danwei'},
{x: 288, y: 632, L: '36mm', T: 'mm', label: '联系', field: 'lianxi'},
{x: 192, y: 680, L: '24mm', T: 'mm', label: '日期', field: 'riqi'},
{x: 480, y: 680, L: '60mm', T: 'mm', label: '重量', field: 'zhongliang'},
{x: 150, y: 728, L: 'mm', T: '88mm', label: '备注', field: 'beizhu'},
{x: 480, y: 448, L: '24mm', T: 'mm', label: '标识', field: 'biaoshi'},
]
let commands = []
commands.push('! 0 200 200 800 1')
commands.push(sizeCmd)
// commands.push(gapCmd)
// commands.push('GAP-SENSE')
// commands.push('CENTER')
fieldOrder.forEach((field, index) => {
if (data[field]) {
const {x, y } = conf[index]
const label = fieldLabels[field] || field
const content = `${data[field]}`
commands.push(`T 1 0 ${x} ${y} ${content}`)
}
})
if (data.qrcode) {
// commands.push(`B QR 624 584 M 2 U 5`)
commands.push(`B QR 624 584 M 2 U 4`)
commands.push(`L0,${data.qrcode}`)
commands.push('ENDQR')
}
commands.push('FORM')
commands.push('PRINT')
return commands.join('\n')
},
generateTSPLWasteLabel(data, sizeCmd, gapCmd) {
const fieldOrder = [
'mingcheng', 'leibie', 'daima', 'xingtai',
'zhuyaochengfen', 'youhaichengfen', 'zhuyi',
'shibiema', 'danwei', 'lianxi', 'riqi',
'zhongliang', 'beizhu'
]
const fieldLabels = {
mingcheng: '名称', leibie: '类别', daima: '代码', xingtai: '形态',
zhuyaochengfen: '主要成分', youhaichengfen: '有害成分', zhuyi: '注意',
shibiema: '识别码', danwei: '单位', lianxi: '联系', riqi: '日期',
zhongliang: '重量', beizhu: '备注'
}
let commands = []
commands.push(sizeCmd)
commands.push(gapCmd)
commands.push('CLS')
fieldOrder.forEach((field, index) => {
if (data[field]) {
const y = 20 + (index * 25)
const label = fieldLabels[field] || field
const content = `${label}: ${data[field]}`
commands.push(`TEXT 20 ${y} "4" 0 1 1 "${content}"`)
}
})
if (data.qrcode) {
commands.push(`QRCODE 150 400 L 5 0 A 0 "${data.qrcode}"`)
}
commands.push('PRINT')
return commands.join('\n')
},
useWasteLabel(data) {
this.printContent = this.generateWasteLabel(data)
},
cleanup() {
if (this.connectedDeviceId) {
uni.closeBLEConnection({
deviceId: this.connectedDeviceId
})
}
}
}
}
</script>
<style scoped>
.container {
padding: 20rpx;
background: #f5f5f5;
min-height: 100vh;
}
.status-section {
display: flex;
background: #fff;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.status-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.status-label {
font-size: 24rpx;
color: #999;
margin-bottom: 8rpx;
}
.status-value {
font-size: 28rpx;
font-weight: bold;
color: #666;
}
.status-value.available {
color: #007aff;
}
.status-value.connected {
color: #34c759;
}
.status-value.auto {
color: #ff9500;
}
.section {
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.printer-name {
font-size: 24rpx;
color: #34c759;
}
.config-info {
background: #f8f8f8;
border-radius: 8rpx;
padding: 16rpx;
margin-bottom: 16rpx;
}
.config-row {
display: flex;
margin-bottom: 12rpx;
}
.config-row:last-child {
margin-bottom: 0;
}
.config-label {
font-size: 24rpx;
color: #666;
min-width: 140rpx;
}
.config-value {
font-size: 24rpx;
color: #333;
flex: 1;
}
.config-value.uuid {
font-family: 'Courier New', monospace;
font-size: 22rpx;
word-break: break-all;
}
.config-actions {
display: flex;
justify-content: flex-end;
}
.empty-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 20rpx;
}
.empty-text {
font-size: 30rpx;
color: #666;
margin-bottom: 16rpx;
}
.empty-hint {
font-size: 24rpx;
color: #999;
margin-bottom: 32rpx;
text-align: center;
}
.print-section {
min-height: 400rpx;
}
.command-type {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.type-label {
font-size: 26rpx;
color: #666;
margin-right: 16rpx;
}
.type-switch {
display: flex;
border: 1rpx solid #007aff;
border-radius: 8rpx;
overflow: hidden;
}
.type-btn {
padding: 12rpx 24rpx;
font-size: 24rpx;
color: #007aff;
background: #fff;
}
.type-btn.active {
background: #007aff;
color: #fff;
}
.paper-size-section {
margin-bottom: 20rpx;
}
.paper-size-label {
font-size: 26rpx;
color: #666;
margin-bottom: 12rpx;
display: block;
}
.paper-size-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.paper-size-item {
flex: 1;
min-width: 200rpx;
padding: 20rpx;
background: #f0f0f0;
border-radius: 8rpx;
border: 2rpx solid transparent;
text-align: center;
}
.paper-size-item.selected {
background: #e8f4ff;
border-color: #007aff;
}
.paper-size-name {
font-size: 24rpx;
color: #333;
}
.paper-size-item.selected .paper-size-name {
color: #007aff;
font-weight: bold;
}
.print-content {
margin-bottom: 20rpx;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.content-title {
font-size: 26rpx;
color: #666;
}
.content-actions {
display: flex;
gap: 12rpx;
}
.content-textarea {
width: 100%;
height: 180rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 16rpx;
font-size: 26rpx;
font-family: 'Courier New', monospace;
box-sizing: border-box;
}
.template-section {
margin-bottom: 20rpx;
}
.template-title {
font-size: 26rpx;
color: #666;
margin-bottom: 12rpx;
display: block;
}
.template-list {
width: 100%;
}
.template-tags {
display: flex;
gap: 12rpx;
padding: 4rpx;
}
.template-tag {
padding: 12rpx 24rpx;
background: #f0f0f0;
border-radius: 8rpx;
font-size: 24rpx;
color: #333;
}
.template-tag:active {
background: #e0e0e0;
}
.connecting-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 20rpx;
}
.connecting-content {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32rpx;
}
.loading-icon {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #007aff;
border-top-color: transparent;
border-radius: 50%;
animation: rotate 1s linear infinite;
margin-bottom: 24rpx;
}
@keyframes rotate {
to { transform: rotate(360deg); }
}
.connecting-text {
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
}
.connecting-hint {
font-size: 24rpx;
color: #999;
}
</style>