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.
426 lines
12 KiB
Vue
426 lines
12 KiB
Vue
<template>
|
|
<div>
|
|
<div class="no-print">
|
|
<div class="controls">
|
|
<h3>Batch Print USB Barcode Labels</h3>
|
|
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
|
|
|
|
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
|
|
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
|
|
<div v-else class="usb-grid">
|
|
<div
|
|
v-for="device in devices"
|
|
:key="device.device_id"
|
|
class="usb-item"
|
|
:class="{ selected: isSelected(device) }"
|
|
@click="toggleDevice(device)"
|
|
>
|
|
<input type="checkbox" :checked="isSelected(device)" @click.stop />
|
|
<label>
|
|
<strong><code>{{ device.device_id || '-' }}</code></strong>
|
|
<div class="alias">{{ device.device_desc || '' }}</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="selected-count">
|
|
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
|
|
(<span class="pages">{{ pageCount }}</span> pages)
|
|
<label style="margin-left: 20px;">Start at cell:
|
|
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
|
|
<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>
|
|
|
|
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</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 sheetPages" :key="pageIdx" class="print-sheet">
|
|
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
|
<div
|
|
v-for="cellNum in 6"
|
|
:key="cellNum"
|
|
class="label-cell"
|
|
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
|
|
>
|
|
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
|
|
<div
|
|
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
|
|
:key="miniIdx"
|
|
class="mini-label"
|
|
:class="miniItem ? 'filled' : 'empty'"
|
|
>
|
|
<template v-if="miniItem">
|
|
<img
|
|
v-if="labelStyle === 'qr'"
|
|
class="qr-img"
|
|
:src="qrImages[`${pageIdx}-${cellNum}-${miniIdx}`] || ''"
|
|
/>
|
|
<div v-else class="barcode-container">
|
|
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
|
|
</div>
|
|
<div class="serial-text">{{ miniItem.device_id }}</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div v-else class="empty-cell-text">Empty</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
import { usbApi } from '@/api'
|
|
import JsBarcode from 'jsbarcode'
|
|
import QRCode from 'qrcode'
|
|
import { getSetting } from '@/utils/siteSettings'
|
|
import { buildQrUrl } from '@/utils/qrTarget'
|
|
|
|
const MINI_LABELS_PER_CELL = 12
|
|
const CELLS_PER_PAGE = 6
|
|
|
|
const devices = ref([])
|
|
const selectedDevices = ref([])
|
|
const loadingDevices = ref(true)
|
|
const startCell = ref('1')
|
|
const barcodeRefs = ref({})
|
|
// usb_label_style setting: 'barcode' (CODE128 of the serial) or 'qr' (QR code
|
|
// linking to the qr_target_usb target, default = the device page).
|
|
const labelStyle = ref('barcode')
|
|
// QR data-URL images keyed like barcodeRefs. Images print reliably; live
|
|
// canvases do not.
|
|
const qrImages = ref({})
|
|
|
|
const pageCount = computed(() => {
|
|
if (selectedDevices.value.length === 0) return 0
|
|
const skippedCells = parseInt(startCell.value) - 1
|
|
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
|
|
const totalCellsNeeded = numCells + skippedCells
|
|
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
|
|
})
|
|
|
|
const sheetPages = computed(() => {
|
|
const result = []
|
|
if (selectedDevices.value.length === 0) return result
|
|
|
|
const startCellNum = parseInt(startCell.value)
|
|
let usbIdx = 0
|
|
|
|
for (let page = 0; page < pageCount.value; page++) {
|
|
const cells = []
|
|
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
|
|
const skipThisCell = page === 0 && cellNum < startCellNum
|
|
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
|
|
|
|
const items = []
|
|
if (hasContent) {
|
|
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
|
|
if (usbIdx < selectedDevices.value.length) {
|
|
items.push(selectedDevices.value[usbIdx])
|
|
usbIdx++
|
|
} else {
|
|
items.push(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
cells.push({ hasContent, items })
|
|
}
|
|
result.push(cells)
|
|
}
|
|
return result
|
|
})
|
|
|
|
onMounted(async () => {
|
|
labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode'
|
|
try {
|
|
const response = await usbApi.list({ perpage: 500 })
|
|
devices.value = response.data.data || []
|
|
} catch (error) {
|
|
console.error('Error loading USB devices:', error)
|
|
} finally {
|
|
loadingDevices.value = false
|
|
}
|
|
})
|
|
|
|
watch([selectedDevices, startCell], async () => {
|
|
await nextTick()
|
|
if (labelStyle.value === 'qr') {
|
|
await generateQrImages()
|
|
} else {
|
|
generateBarcodes()
|
|
}
|
|
}, { deep: true })
|
|
|
|
async function generateQrImages() {
|
|
const next = {}
|
|
for (let pageIdx = 0; pageIdx < sheetPages.value.length; pageIdx++) {
|
|
const page = sheetPages.value[pageIdx]
|
|
for (let cellIdx = 0; cellIdx < page.length; cellIdx++) {
|
|
const cell = page[cellIdx]
|
|
if (!cell.hasContent) continue
|
|
for (let miniIdx = 0; miniIdx < cell.items.length; miniIdx++) {
|
|
const item = cell.items[miniIdx]
|
|
if (!item) continue
|
|
try {
|
|
const url = await buildQrUrl('qr_target_usb', `/usb/${encodeURIComponent(item.device_id)}`, {
|
|
id: item.device_id || '',
|
|
serialnumber: item.device_id || '',
|
|
alias: item.device_desc || '',
|
|
})
|
|
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] = await QRCode.toDataURL(url, {
|
|
margin: 0,
|
|
width: 150,
|
|
errorCorrectionLevel: 'M',
|
|
})
|
|
} catch (e) {
|
|
console.error('QR error:', item.device_id, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
qrImages.value = next
|
|
}
|
|
|
|
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
|
|
if (el) {
|
|
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
|
|
}
|
|
}
|
|
|
|
function generateBarcodes() {
|
|
sheetPages.value.forEach((page, pageIdx) => {
|
|
page.forEach((cell, cellIdx) => {
|
|
if (!cell.hasContent) return
|
|
const cellNum = cellIdx + 1
|
|
cell.items.forEach((item, miniIdx) => {
|
|
if (!item) return
|
|
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'
|
|
})
|
|
} catch (e) {
|
|
console.error('Barcode error:', item.device_id, e)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
function isSelected(device) {
|
|
return selectedDevices.value.some(d => d.device_id === device.device_id)
|
|
}
|
|
|
|
function toggleDevice(device) {
|
|
const idx = selectedDevices.value.findIndex(d => d.device_id === device.device_id)
|
|
if (idx > -1) {
|
|
selectedDevices.value.splice(idx, 1)
|
|
} else {
|
|
selectedDevices.value.push(device)
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedDevices.value = []
|
|
}
|
|
|
|
function selectAll() {
|
|
selectedDevices.value = [...devices.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;
|
|
}
|
|
|
|
.usb-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
gap: 10px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border);
|
|
padding: 10px;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.usb-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;
|
|
}
|
|
.usb-item:hover { border-color: var(--primary); }
|
|
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
|
.usb-item input { margin-right: 10px; }
|
|
.usb-item label { cursor: pointer; flex: 1; }
|
|
.usb-item .alias { 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-cell {
|
|
width: 3in;
|
|
height: 3in;
|
|
position: absolute;
|
|
box-sizing: border-box;
|
|
border: 1px dashed #ccc;
|
|
overflow: hidden;
|
|
}
|
|
.label-cell.has-content { border: 1px solid var(--primary); }
|
|
.label-cell.empty { background: #fafafa; }
|
|
|
|
.cell-1 { top: 0.875in; left: 1.1875in; }
|
|
.cell-2 { top: 0.875in; left: 4.3125in; }
|
|
.cell-3 { top: 4in; left: 1.1875in; }
|
|
.cell-4 { top: 4in; left: 4.3125in; }
|
|
.cell-5 { top: 7.125in; left: 1.1875in; }
|
|
.cell-6 { top: 7.125in; left: 4.3125in; }
|
|
|
|
.mini-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1in);
|
|
grid-template-rows: repeat(4, 0.75in);
|
|
width: 3in;
|
|
height: 3in;
|
|
}
|
|
|
|
.mini-label {
|
|
width: 1in;
|
|
height: 0.75in;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
padding: 0.02in;
|
|
border: 1px dotted #ddd;
|
|
overflow: hidden;
|
|
}
|
|
.mini-label.filled { border: 1px solid #999; }
|
|
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
|
|
|
|
.barcode-container { text-align: center; line-height: 0; }
|
|
.barcode-container svg { max-width: 0.9in; height: 24px; }
|
|
|
|
.qr-img { width: 0.48in; height: 0.48in; }
|
|
|
|
.serial-text {
|
|
font-size: 6pt;
|
|
font-weight: bold;
|
|
font-family: monospace;
|
|
text-align: center;
|
|
margin-top: 1px;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
|
|
.empty-cell-text {
|
|
color: #ccc;
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
}
|
|
|
|
@media print {
|
|
/* Force the barcodes/borders to print even when "Background graphics" is
|
|
off. */
|
|
body, .print-sheet, .label-cell, .mini-label, .barcode-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-cell { border: none !important; }
|
|
.label-cell.empty { visibility: hidden; }
|
|
.mini-label { border: 1px dotted #ccc !important; }
|
|
.mini-label.empty { visibility: hidden; }
|
|
}
|
|
</style>
|