Relocate warranty, measuringtools, network, printers, usb, notifications, computers, and slides into plugins/<name>/frontend/. Each plugin's views are pulled from wherever they lived (own dir, plus the shared views/settings/, views/reports/, views/print/ dirs, and top-level views) into the plugin's frontend/views/, and its route file becomes the self-contained routes.js. Handled the messy cases: - computers: name mismatch (its views live in views/pcs/) - moved by following the route file's own imports, so the dir name did not matter. Its OS/access- protocol/PC-type settings views move with it (only computers.js routed them). - network: NetworkHub's sibling sub-views (NetworkDevicesList, SubnetsBrowse, not directly routed) moved too so its `./` imports resolve. - printers: the qrLogo helper is SHARED with core AssetLabel, so it stays in views/print/ and PrinterQR imports it via @/views/print/qrLogo. - slides: route file is toplevel-only (TVDashboard); SlideManager stays core (core.js routes /settings/slides). frontend/src/views/ now holds only core views; frontend/src/router/routes/ holds only core.js. All 13 plugins are self-contained under plugins/<name>/frontend/. Verified live: Network (hub + moved sub-views), Computers (name mismatch), GE-Enforce (helper), printedparts all render from their staged frontends. Build + 58 vitest + naming green.
308 lines
9.9 KiB
Vue
308 lines
9.9 KiB
Vue
<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>
|