labels: one module knows how to draw a code, seven views stop guessing

Three core pages and four plugin pages each imported qrcode and jsbarcode
directly, and each carried its own answer to the same questions: what margin,
what width, which error correction, how big a module must be before a scanner
can read it. The answers had already drifted - margin 0 in one place and 2 in
another, width 150 against 160 - and on a label that is the difference between
a sticker that scans and one that does not.

frontend/src/utils/codes.js owns it now: the label-stock presets, the quiet-zone
and margin defaults, CODE128 with no printed value, and the printer-resolution
arithmetic that only the Tech Tools generator had. A view passes what is
specific to its own label and nothing else - MachineBadge still asks for CODE39,
because the badge readers predate the shop-floor scanners and decode nothing
else, and that is exactly the kind of thing a call site should say out loud.

views/print/qrLogo.js is folded in rather than left as a second half-shared
helper that only some of the pages reached into.

The check script now fails a build that imports either library outside that
module. Without it this re-forks within a month: the next label page starts by
copying the nearest existing one, which is how it happened the first time.

Tests cover the part no amount of looking at a screen verifies - a QR that
looks fine at 96 dpi on a monitor can be unreadable at 203 dpi on half-inch
stock.
This commit is contained in:
cproudlock
2026-08-14 14:03:49 -04:00
parent b37c08eb5b
commit c648bdf560
12 changed files with 836 additions and 633 deletions

View File

@@ -52,7 +52,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import QRCode from 'qrcode'
import { qrPngDataUrl } from '@/utils/codes'
import { printedpartsApi } from '@/api'
const items = ref([])
@@ -123,9 +123,7 @@ watch(printLabels, async labels => {
await Promise.all(labels.map(async (label, index) => {
// margin 2 = quiet zone (scannability); EC 'M' keeps modules big for the
// short payload on tiny media; no logo overlay on a 0.36in code.
urls[index] = await QRCode.toDataURL(labelPayload(label), {
margin: 2, errorCorrectionLevel: 'M', width: 160
})
urls[index] = await qrPngDataUrl(labelPayload(label), { margin: 2, width: 160 })
}))
qrDataUrls.value = urls
}, { deep: true, immediate: true })

View File

@@ -1,307 +1,307 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print Printer QR Codes</h3>
<p>Select printers to print (6 per page):</p>
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
<div v-else class="printer-grid">
<div
v-for="printer in printers"
:key="printer.assetid"
class="printer-item"
:class="{ selected: isSelected(printer) }"
@click="togglePrinter(printer)"
>
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
<label>
<strong>{{ displayName(printer) }}</strong>
<div class="model">{{ printer.printer?.modelname || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
(<span class="pages">{{ pageCount }}</span> pages)
</div>
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
>
<template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div>
</div>
</template>
<span v-else class="empty-label">Empty</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/views/print/qrLogo'
import { buildQrUrl } from '@/utils/qrTarget'
const printers = ref([])
const selectedPrinters = ref([])
const loadingPrinters = ref(true)
// QR codes rendered to data-URL images (not live <canvas>): canvases are
// unreliable in print output, images print every time.
const qrImages = ref({})
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
const pages = computed(() => {
const result = []
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
const page = []
for (let j = 0; j < 6; j++) {
page.push(selectedPrinters.value[i + j] || null)
}
result.push(page)
}
return result
})
onMounted(async () => {
try {
const response = await printersApi.list({ perpage: 500 })
printers.value = response.data.data || []
} catch (error) {
console.error('Error loading printers:', error)
} finally {
loadingPrinters.value = false
}
})
watch(selectedPrinters, async () => {
await nextTick()
generateQRCodes()
}, { deep: true })
async function generateQRCodes() {
const next = {}
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
const page = pages.value[pageIdx]
for (let idx = 0; idx < page.length; idx++) {
const printer = page[idx]
if (!printer) continue
const pos = idx + 1
const detailId = printer.printer?.printerid || printer.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: printer.printer?.printerid || '',
assetid: printer.assetid || '',
assetnumber: printer.assetnumber || '',
serialnumber: printer.serialnumber || '',
ip: getIp(printer) || '',
hostname: printer.printer?.windowsname || printer.name || '',
})
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
}
}
qrImages.value = next
}
function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
}
function getIp(printer) {
// Check direct ipaddress field first (from list API)
if (printer.ipaddress && printer.ipaddress !== 'USB') return printer.ipaddress
// Fall back to communications array (from detail API)
if (!printer.communications?.length) return null
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
return primary?.ipaddress || primary?.address || null
}
function isSelected(printer) {
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
}
function togglePrinter(printer) {
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
if (idx > -1) {
selectedPrinters.value.splice(idx, 1)
} else {
selectedPrinters.value.push(printer)
}
}
function clearSelection() {
selectedPrinters.value = []
}
function selectAll() {
selectedPrinters.value = [...printers.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--danger);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--success);
color: white;
border: none;
border-radius: 5px;
}
.printer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.printer-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.printer-item:hover { border-color: var(--primary); }
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.printer-item input { margin-right: 10px; }
.printer-item label { cursor: pointer; flex: 1; }
.printer-item .model { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
border: 1px dashed #ccc;
}
.label.filled { border: 2px solid var(--primary); }
.label.empty { background: #fafafa; }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
.empty-label { color: #999; font-size: 14px; }
@media print {
/* Force the browser to print rendered images/colors even when the user's
"Background graphics" option is off. Without this the QR images and
borders can drop out of the printout. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label { border: none !important; }
.label.empty { visibility: hidden; }
}
</style>
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print Printer QR Codes</h3>
<p>Select printers to print (6 per page):</p>
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
<div v-else class="printer-grid">
<div
v-for="printer in printers"
:key="printer.assetid"
class="printer-item"
:class="{ selected: isSelected(printer) }"
@click="togglePrinter(printer)"
>
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
<label>
<strong>{{ displayName(printer) }}</strong>
<div class="model">{{ printer.printer?.modelname || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
(<span class="pages">{{ pageCount }}</span> pages)
</div>
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
>
<template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div>
</div>
</template>
<span v-else class="empty-label">Empty</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget'
const printers = ref([])
const selectedPrinters = ref([])
const loadingPrinters = ref(true)
// QR codes rendered to data-URL images (not live <canvas>): canvases are
// unreliable in print output, images print every time.
const qrImages = ref({})
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
const pages = computed(() => {
const result = []
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
const page = []
for (let j = 0; j < 6; j++) {
page.push(selectedPrinters.value[i + j] || null)
}
result.push(page)
}
return result
})
onMounted(async () => {
try {
const response = await printersApi.list({ perpage: 500 })
printers.value = response.data.data || []
} catch (error) {
console.error('Error loading printers:', error)
} finally {
loadingPrinters.value = false
}
})
watch(selectedPrinters, async () => {
await nextTick()
generateQRCodes()
}, { deep: true })
async function generateQRCodes() {
const next = {}
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
const page = pages.value[pageIdx]
for (let idx = 0; idx < page.length; idx++) {
const printer = page[idx]
if (!printer) continue
const pos = idx + 1
const detailId = printer.printer?.printerid || printer.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: printer.printer?.printerid || '',
assetid: printer.assetid || '',
assetnumber: printer.assetnumber || '',
serialnumber: printer.serialnumber || '',
ip: getIp(printer) || '',
hostname: printer.printer?.windowsname || printer.name || '',
})
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
}
}
qrImages.value = next
}
function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
}
function getIp(printer) {
// Check direct ipaddress field first (from list API)
if (printer.ipaddress && printer.ipaddress !== 'USB') return printer.ipaddress
// Fall back to communications array (from detail API)
if (!printer.communications?.length) return null
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
return primary?.ipaddress || primary?.address || null
}
function isSelected(printer) {
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
}
function togglePrinter(printer) {
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
if (idx > -1) {
selectedPrinters.value.splice(idx, 1)
} else {
selectedPrinters.value.push(printer)
}
}
function clearSelection() {
selectedPrinters.value = []
}
function selectAll() {
selectedPrinters.value = [...printers.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--danger);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--success);
color: white;
border: none;
border-radius: 5px;
}
.printer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.printer-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.printer-item:hover { border-color: var(--primary); }
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.printer-item input { margin-right: 10px; }
.printer-item label { cursor: pointer; flex: 1; }
.printer-item .model { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
border: 1px dashed #ccc;
}
.label.filled { border: 2px solid var(--primary); }
.label.empty { background: #fafafa; }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
.empty-label { color: #999; font-size: 14px; }
@media print {
/* Force the browser to print rendered images/colors even when the user's
"Background graphics" option is off. Without this the QR images and
borders can drop out of the printout. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label { border: none !important; }
.label.empty { visibility: hidden; }
}
</style>

View File

@@ -1,187 +1,187 @@
<template>
<div>
<div class="no-print">
<button class="print-btn" :disabled="!printer" @click="print">Print QR Code</button>
<label>Position:
<select class="position-select" v-model="position">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="printer" class="print-sheet">
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
>
<template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-msg">Printer not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/views/print/qrLogo'
import { buildQrUrl } from '@/utils/qrTarget'
const route = useRoute()
const loading = ref(true)
const printer = ref(null)
const position = ref('1')
// Render QR to a data-URL image, not a live <canvas>: canvases are unreliable
// in print output, images print every time.
const qrImage = ref('')
const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
// Fall back to communications array (from detail API)
if (!printer.value?.communications?.length) return null
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primary?.ipaddress || primary?.address || null
})
onMounted(async () => {
try {
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error loading printer:', error)
} finally {
loading.value = false
await nextTick()
generateQR()
}
})
watch(position, async () => {
await nextTick()
generateQR()
})
async function generateQR() {
if (!printer.value) return
const p = printer.value
const detailId = p.printer?.printerid || p.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: p.printer?.printerid || '',
assetid: p.assetid || '',
assetnumber: p.assetnumber || '',
serialnumber: p.serialnumber || '',
ip: ipAddress.value || '',
hostname: p.printer?.windowsname || p.name || '',
})
qrImage.value = await renderQrDataUrl(qrUrl)
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; text-align: center; padding: 20px; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin: 5px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: var(--text-light);
}
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
}
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
}
.label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid var(--primary); }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
@media print {
/* Force rendered images/colors to print even when "Background graphics" is
off, otherwise the QR image can drop out. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
}
</style>
<template>
<div>
<div class="no-print">
<button class="print-btn" :disabled="!printer" @click="print">Print QR Code</button>
<label>Position:
<select class="position-select" v-model="position">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="printer" class="print-sheet">
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
>
<template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="qr-container">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-msg">Printer not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget'
const route = useRoute()
const loading = ref(true)
const printer = ref(null)
const position = ref('1')
// Render QR to a data-URL image, not a live <canvas>: canvases are unreliable
// in print output, images print every time.
const qrImage = ref('')
const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
// Fall back to communications array (from detail API)
if (!printer.value?.communications?.length) return null
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primary?.ipaddress || primary?.address || null
})
onMounted(async () => {
try {
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error loading printer:', error)
} finally {
loading.value = false
await nextTick()
generateQR()
}
})
watch(position, async () => {
await nextTick()
generateQR()
})
async function generateQR() {
if (!printer.value) return
const p = printer.value
const detailId = p.printer?.printerid || p.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: p.printer?.printerid || '',
assetid: p.assetid || '',
assetnumber: p.assetnumber || '',
serialnumber: p.serialnumber || '',
ip: ipAddress.value || '',
hostname: p.printer?.windowsname || p.name || '',
})
qrImage.value = await renderQrDataUrl(qrUrl)
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; text-align: center; padding: 20px; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin: 5px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: var(--text-light);
}
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
}
.label {
width: 3in;
height: 3in;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.1in;
box-sizing: border-box;
}
.label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid var(--primary); }
.qr-img { width: 144px; height: 144px; display: block; }
.pos-1 { top: 0.875in; left: 1.1875in; }
.pos-2 { top: 0.875in; left: 4.3125in; }
.pos-3 { top: 4in; left: 1.1875in; }
.pos-4 { top: 4in; left: 4.3125in; }
.pos-5 { top: 7.125in; left: 1.1875in; }
.pos-6 { top: 7.125in; left: 4.3125in; }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
@media print {
/* Force rendered images/colors to print even when "Background graphics" is
off, otherwise the QR image can drop out. */
body, .print-sheet, .label, .qr-img, .qr-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
}
</style>

View File

@@ -163,20 +163,14 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import QRCode from 'qrcode'
import JsBarcode from 'jsbarcode'
import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri }
from '@/utils/codes'
// Rendering thousands of codes locks the tab, so stop at a number a person
// would actually feed a label printer in one run and SAY what was dropped.
const MAX_LABELS = 500
const PRESETS = [
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
]
const PRESETS = LABEL_PRESETS
const source = ref('single')
const codetype = ref('qr')
@@ -326,25 +320,20 @@ const visibleLabels = computed(() =>
const qrModules = computed(() => {
const content = labels.value[0]?.content
if (!content || codetype.value !== 'qr') return 0
try {
return QRCode.create(content, { errorCorrectionLevel: errorcorrection.value }).modules.size
} catch {
return 0
}
return qrModuleCount(content, errorcorrection.value)
})
const fitAdvice = computed(() => {
const modules = qrModules.value
if (!modules) return ''
const dotsPerModule = (codesize.value * dpi.value) / modules
const whole = Math.floor(dotsPerModule)
if (whole < 2) {
const fit = qrFit(labels.value[0]?.content, {
sizeInches: codesize.value, dpi: dpi.value, errorCorrection: errorcorrection.value,
})
if (!fit || codetype.value !== 'qr') return ''
const { modules, dotsPerModule, wholeDots: whole, snapped, moduleMm } = fit
if (fit.tooSmall) {
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each - too small to scan `
+ `reliably. Shorten the content, drop error correction, or use bigger stock.`
}
const snapped = (modules * whole) / dpi.value
const moduleMm = (whole / dpi.value) * 25.4
if (Math.abs(snapped - codesize.value) < 0.002) {
if (fit.even) {
return `${modules} modules at exactly ${whole} dots each (${moduleMm.toFixed(3)} mm). Good.`
}
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each. Uneven - `
@@ -361,30 +350,13 @@ const fitAdvice = computed(() => {
//
// margin 0 on the QR: the quiet zone is blank label supplied by --tool-quiet,
// so none of the code box is spent on white we cannot then adjust.
function svgDataUri(svg) {
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
}
async function renderOne(row) {
const text = row.content
if (!text) return ''
if (codetype.value === 'qr') {
const svg = await QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: errorcorrection.value,
margin: 0,
})
return svgDataUri(svg)
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
}
const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
JsBarcode(element, text, {
format: 'CODE128',
displayValue: false,
margin: 0,
width: 2,
height: 100,
})
return svgDataUri(new XMLSerializer().serializeToString(element))
return barcodeSvgDataUri(text)
}
let renderToken = 0

View File

@@ -83,8 +83,7 @@
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usbApi } from '@/api'
import JsBarcode from 'jsbarcode'
import QRCode from 'qrcode'
import { barcodeInto, qrPngDataUrl } from '@/utils/codes'
import { getSetting } from '@/utils/siteSettings'
import { buildQrUrl } from '@/utils/qrTarget'
@@ -180,11 +179,8 @@ async function generateQrImages() {
serialnumber: item.device_id || '',
alias: item.device_desc || '',
})
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] = await QRCode.toDataURL(url, {
margin: 0,
width: 150,
errorCorrectionLevel: 'M',
})
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] =
await qrPngDataUrl(url, { width: 150 })
} catch (e) {
console.error('QR error:', item.device_id, e)
}
@@ -210,13 +206,8 @@ function generateBarcodes() {
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
if (!el) return
try {
JsBarcode(el, item.device_id, {
format: 'CODE128',
width: 1,
height: 22,
displayValue: false,
margin: 0,
background: 'transparent'
barcodeInto(el, item.device_id, {
width: 1, height: 22, background: 'transparent',
})
} catch (e) {
console.error('Barcode error:', item.device_id, e)