Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
425
frontend/src/views/print/AssetLabel.vue
Normal file
425
frontend/src/views/print/AssetLabel.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Controls (never printed) -->
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Print Asset Label</h3>
|
||||
<div v-if="loading" class="loading-msg">Loading...</div>
|
||||
<div v-else-if="!asset" class="error-msg">Asset not found</div>
|
||||
<template v-else>
|
||||
<div class="control-row">
|
||||
<label>Style
|
||||
<select v-model="style">
|
||||
<option value="card">Card (badge)</option>
|
||||
<option value="plain">Plain (code only)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Code type
|
||||
<select v-model="codetype">
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Encodes
|
||||
<select v-model="encodes">
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
|
||||
This tool has no location; the label falls back to the asset page.
|
||||
</p>
|
||||
<div class="control-row">
|
||||
<label>Output
|
||||
<select v-model="outputMode">
|
||||
<option value="single">Single label (standalone)</option>
|
||||
<option value="uline-sheet">Place on ULINE 6-up sheet</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Cell picker: pick which cell of a partially-used ULINE sheet gets -->
|
||||
<!-- this one label. 2x3 grid mirrors the physical cell positions. -->
|
||||
<div v-if="outputMode === 'uline-sheet'" class="cell-picker">
|
||||
<span class="cell-picker-label">Sheet cell</span>
|
||||
<div class="cell-grid">
|
||||
<button
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
type="button"
|
||||
class="cell-btn"
|
||||
:class="{ active: cellNum === sheetCell }"
|
||||
@click="sheetCell = cellNum"
|
||||
>{{ cellNum }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="print-btn" @click="print">Print</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable area: standalone single label (unchanged default). -->
|
||||
<div v-if="asset && outputMode === 'single'" class="label-sheet">
|
||||
<div class="asset-label" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
||||
<div class="label-fields">
|
||||
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="code-area">
|
||||
<template v-if="codeText">
|
||||
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
||||
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
||||
<div class="code-caption">{{ caption }}</div>
|
||||
</template>
|
||||
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One label placed at the chosen cell of a ULINE 6-up sheet, other five -->
|
||||
<!-- blank, so it lands on the right spot of a partially-used sheet. Cell -->
|
||||
<!-- layout/CSS replicated from AssetLabelBatch (left untouched) to keep -->
|
||||
<!-- batch's verified rendering isolated. Only one branch renders at a time -->
|
||||
<!-- so the shared barcodeEl ref resolves to whichever label is live. -->
|
||||
<div v-else-if="asset" class="sheets-container">
|
||||
<div class="print-sheet">
|
||||
<div class="sheet-label no-print">ULINE 6-up sheet - cell {{ sheetCell }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label"
|
||||
:class="[`pos-${cellNum}`, cellNum === sheetCell ? 'active' : 'inactive']"
|
||||
>
|
||||
<div v-if="cellNum === sheetCell" class="asset-label in-cell" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
||||
<div class="label-fields">
|
||||
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="code-area">
|
||||
<template v-if="codeText">
|
||||
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
||||
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
||||
<div class="code-caption">{{ caption }}</div>
|
||||
</template>
|
||||
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import {
|
||||
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
|
||||
effectiveEncodes as effEncodes, resolveCodeText as resolveText, captionFor,
|
||||
} from './assetLabel'
|
||||
|
||||
const route = useRoute()
|
||||
const assettype = route.params.assettype
|
||||
const config = TYPE_CONFIG[assettype] || null
|
||||
|
||||
const loading = ref(true)
|
||||
const asset = ref(null)
|
||||
const barcodeEl = ref(null)
|
||||
const qrImage = ref('')
|
||||
// The resolved string the code encodes; empty when the chosen field has no
|
||||
// value (e.g. serial number on an asset with none) so the UI can explain it.
|
||||
const codeText = ref('')
|
||||
|
||||
const style = ref('card')
|
||||
const codetype = ref('qr')
|
||||
const encodes = ref('assetpage')
|
||||
// Output target: 'single' standalone label, or 'uline-sheet' placing this one
|
||||
// label at sheetCell (1-6) of a ULINE 6-up sheet.
|
||||
const outputMode = ref('single')
|
||||
const sheetCell = ref(1)
|
||||
|
||||
const hasLocation = computed(() => hasLocationType(assettype))
|
||||
|
||||
const extension = computed(() => (asset.value && config) ? asset.value[config.extkey] : null)
|
||||
|
||||
const cardTitle = computed(() => {
|
||||
if (!asset.value) return ''
|
||||
const ext = extension.value || {}
|
||||
return ext.modelname
|
||||
|| ext.measuringtooltypename
|
||||
|| asset.value.assettypename
|
||||
|| (config ? config.label : 'Asset')
|
||||
})
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
const ext = extension.value
|
||||
return (ext && ext.imageurl) ? ext.imageurl : null
|
||||
})
|
||||
|
||||
// Small identity table shown on the card.
|
||||
const identityFields = computed(() => {
|
||||
if (!asset.value) return []
|
||||
const rows = []
|
||||
if (asset.value.assetnumber) rows.push({ label: 'Asset #', value: asset.value.assetnumber })
|
||||
if (asset.value.serialnumber) rows.push({ label: 'Serial', value: asset.value.serialnumber })
|
||||
if (asset.value.name) rows.push({ label: 'Name', value: asset.value.name })
|
||||
if (asset.value.locationname) rows.push({ label: 'Location', value: asset.value.locationname })
|
||||
return rows
|
||||
})
|
||||
|
||||
// Human caption printed under the code.
|
||||
const caption = computed(() => captionFor(asset.value, encodes.value))
|
||||
|
||||
// Human phrase for the current encode mode, used in the "nothing to encode"
|
||||
// message.
|
||||
const ENCODE_LABELS = {
|
||||
assetnumber: 'asset number', serialnumber: 'serial number',
|
||||
location: 'inspection location', assetpage: 'page link', custom: 'target',
|
||||
}
|
||||
const encodeLabel = computed(() =>
|
||||
ENCODE_LABELS[effEncodes(encodes.value, asset.value)] || 'value')
|
||||
|
||||
async function renderCode() {
|
||||
const text = await resolveText(assettype, asset.value, encodes.value)
|
||||
codeText.value = text
|
||||
if (!text) { qrImage.value = ''; return }
|
||||
if (codetype.value === 'qr') {
|
||||
qrImage.value = await renderQrDataUrl(text)
|
||||
} else {
|
||||
await nextTick()
|
||||
if (!barcodeEl.value) return
|
||||
try {
|
||||
JsBarcode(barcodeEl.value, text, {
|
||||
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Barcode error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!config) { loading.value = false; return }
|
||||
style.value = (await getSetting('label_default_style', 'card')) === 'plain' ? 'plain' : 'card'
|
||||
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
||||
encodes.value = await resolveDefaultEncodes(assettype)
|
||||
try {
|
||||
const response = await config.api.get(route.params.id)
|
||||
asset.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading asset:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
renderCode()
|
||||
}
|
||||
})
|
||||
|
||||
// outputMode/sheetCell re-mount the live label element, so redraw the barcode
|
||||
// into the newly rendered svg (the qr path is just an <img> src, unaffected).
|
||||
watch([style, codetype, encodes, outputMode, sheetCell], renderCode)
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Standalone label is a small stock; the ULINE sheet is a letter page. Named */
|
||||
/* pages let each render its own size (only one branch renders at a time). */
|
||||
@page { size: 2.13in 3.38in; margin: 0; }
|
||||
@page uline { size: letter; margin: 0; }
|
||||
.print-sheet { page: uline; }
|
||||
|
||||
.no-print { padding: 20px; }
|
||||
.controls {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.controls h3 { margin-top: 0; }
|
||||
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
|
||||
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
|
||||
.control-row select { padding: 6px; font-size: 0.875rem; }
|
||||
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 0 0 12px; }
|
||||
|
||||
/* Cell picker: 2x3 grid mirroring the physical ULINE cell positions. */
|
||||
.cell-picker { margin-bottom: 12px; }
|
||||
.cell-picker-label { display: block; font-size: 0.875rem; margin-bottom: 4px; }
|
||||
.cell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 3rem);
|
||||
grid-auto-rows: 3rem;
|
||||
gap: 6px;
|
||||
}
|
||||
.cell-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cell-btn:hover { border-color: var(--primary); }
|
||||
.cell-btn.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.print-btn:hover { background: var(--primary-dark); }
|
||||
|
||||
.loading-msg, .error-msg { padding: 1rem 0; color: var(--text-light); }
|
||||
|
||||
.label-sheet { display: flex; justify-content: center; padding: 20px 0; }
|
||||
|
||||
.asset-label {
|
||||
width: 2.13in;
|
||||
min-height: 3.38in;
|
||||
background: white;
|
||||
color: #000;
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
padding: 0.15in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.asset-label.plain { justify-content: center; min-height: 2in; }
|
||||
|
||||
.label-title {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 0.08in;
|
||||
}
|
||||
.label-image {
|
||||
max-width: 1.6in;
|
||||
max-height: 1.2in;
|
||||
object-fit: contain;
|
||||
margin-bottom: 0.08in;
|
||||
}
|
||||
.label-fields { width: 100%; margin-bottom: 0.08in; }
|
||||
.label-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font-size: 8pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label { color: #555; }
|
||||
.field-value { font-weight: bold; text-align: right; word-break: break-all; }
|
||||
|
||||
.code-area {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.code-qr { width: 1.5in; height: 1.5in; }
|
||||
.code-barcode { width: 1.8in; height: 0.9in; }
|
||||
.code-caption {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
margin-top: 0.02in;
|
||||
}
|
||||
.code-missing {
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
padding: 0.3in 0.1in;
|
||||
}
|
||||
|
||||
/* ULINE 6-up sheet (replicated from AssetLabelBatch; batch left untouched). */
|
||||
.sheets-container { display: flex; justify-content: center; padding: 20px 0; }
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
.label {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
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); }
|
||||
|
||||
/* ULINE S-5627 / 6-up 3in cell positions on a letter sheet (match batch). */
|
||||
.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; }
|
||||
|
||||
/* The label shrunk to fit a 3in cell (standalone stock is taller than 3in). */
|
||||
.label .asset-label.in-cell {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.label .asset-label.in-cell .label-title { font-size: 10pt; margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .label-image { max-width: 1.2in; max-height: 0.7in; margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .label-fields { margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .code-area { margin-top: 0.05in; }
|
||||
.label .asset-label.in-cell .code-qr { width: 1.1in; height: 1.1in; }
|
||||
.label .asset-label.in-cell .code-barcode { width: 2.2in; height: 0.7in; }
|
||||
.label .asset-label.in-cell .code-caption { font-size: 10pt; }
|
||||
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.label-sheet { padding: 0; }
|
||||
.asset-label { border: none; }
|
||||
.sheets-container { padding: 0; }
|
||||
.print-sheet { border: none; }
|
||||
.label { border: none !important; }
|
||||
.label.inactive { visibility: hidden; }
|
||||
body, .asset-label, .code-qr, .code-barcode {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
501
frontend/src/views/print/AssetLabelBatch.vue
Normal file
501
frontend/src/views/print/AssetLabelBatch.vue
Normal file
@@ -0,0 +1,501 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Controls (never printed) -->
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Batch Print {{ config ? config.label : 'Asset' }} Labels</h3>
|
||||
<p v-if="!config" class="error-msg">Unknown asset type.</p>
|
||||
<template v-else>
|
||||
<div class="control-row">
|
||||
<label>Format
|
||||
<select v-model="format">
|
||||
<option value="uline6">ULINE 6-up (3 in x 3 in, 6 per page)</option>
|
||||
<option value="mini72">Mini 72-up (12 per cell, 72 per page)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Code type
|
||||
<select v-model="codetype">
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Encodes
|
||||
<select v-model="encodes">
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Start at cell
|
||||
<select v-model="startCell">
|
||||
<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="loadingAssets" class="loading-msg">Loading assets...</div>
|
||||
<div v-else-if="assets.length === 0" class="loading-msg">No assets found.</div>
|
||||
<div v-else class="asset-grid">
|
||||
<div
|
||||
v-for="asset in assets"
|
||||
:key="asset.assetid"
|
||||
class="asset-item"
|
||||
:class="{ selected: isSelected(asset) }"
|
||||
@click="toggle(asset)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(asset)" @click.stop />
|
||||
<label>
|
||||
<strong>{{ asset.assetnumber || '-' }}</strong>
|
||||
<div class="asset-sub">{{ asset.name || '' }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selected.length }}</span>
|
||||
(<span class="pages">{{ pageCount }}</span> page{{ pageCount === 1 ? '' : 's' }})
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selected.length === 0" @click="print">Print Labels</button>
|
||||
<button class="secondary-btn" @click="selectAll">Select All</button>
|
||||
<button class="secondary-btn" @click="clearSelection">Clear</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable sheets -->
|
||||
<div class="sheets-container">
|
||||
<!-- ULINE 6-up: one asset per 3in cell -->
|
||||
<template v-if="format === 'uline6'">
|
||||
<div v-for="(page, pageIdx) in uline6Pages" :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"
|
||||
:class="[`pos-${cellNum}`, page[cellNum - 1] ? 'active' : 'inactive']"
|
||||
>
|
||||
<template v-if="page[cellNum - 1]">
|
||||
<div class="model-name">{{ titleFor(page[cellNum - 1]) }}</div>
|
||||
<div class="qr-container">
|
||||
<img
|
||||
v-if="codetype === 'qr'"
|
||||
class="qr-img"
|
||||
:src="qrMap[page[cellNum - 1].assetid] || ''"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
:ref="el => setBarcodeRef(el, page[cellNum - 1].assetid, 'u')"
|
||||
class="barcode-svg"
|
||||
></svg>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ captionMap[page[cellNum - 1].assetid] }}</div>
|
||||
<div v-if="page[cellNum - 1].name" class="info-row">{{ page[cellNum - 1].name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mini 72-up: 6 cells x 12 mini labels -->
|
||||
<template v-else>
|
||||
<div v-for="(page, pageIdx) in mini72Pages" :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="(item, miniIdx) in page[cellNum - 1].items"
|
||||
:key="miniIdx"
|
||||
class="mini-label"
|
||||
:class="item ? 'filled' : 'empty'"
|
||||
>
|
||||
<template v-if="item">
|
||||
<img v-if="codetype === 'qr'" class="mini-qr" :src="qrMap[item.assetid] || ''" />
|
||||
<svg v-else :ref="el => setBarcodeRef(el, item.assetid, 'm')" class="mini-barcode"></svg>
|
||||
<div class="serial-text">{{ captionMap[item.assetid] }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-cell-text">Empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import {
|
||||
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
|
||||
resolveCodeText, captionFor,
|
||||
} from './assetLabel'
|
||||
|
||||
const MINI_PER_CELL = 12
|
||||
const CELLS_PER_PAGE = 6
|
||||
|
||||
const route = useRoute()
|
||||
const assettype = route.params.assettype
|
||||
const config = TYPE_CONFIG[assettype] || null
|
||||
|
||||
const assets = ref([])
|
||||
const selected = ref([])
|
||||
const loadingAssets = ref(true)
|
||||
const format = ref('uline6')
|
||||
const codetype = ref('qr')
|
||||
const encodes = ref('assetpage')
|
||||
const startCell = ref('1')
|
||||
|
||||
// assetid -> encoded code text / caption / QR data URL. Barcodes render to refs.
|
||||
const codeMap = ref({})
|
||||
const captionMap = ref({})
|
||||
const qrMap = ref({})
|
||||
const barcodeRefs = ref({})
|
||||
|
||||
const hasLocation = computed(() => hasLocationType(assettype))
|
||||
|
||||
function isSelected(asset) {
|
||||
return selected.value.some(a => a.assetid === asset.assetid)
|
||||
}
|
||||
function toggle(asset) {
|
||||
const idx = selected.value.findIndex(a => a.assetid === asset.assetid)
|
||||
if (idx > -1) selected.value.splice(idx, 1)
|
||||
else selected.value.push(asset)
|
||||
}
|
||||
function selectAll() { selected.value = [...assets.value] }
|
||||
function clearSelection() { selected.value = [] }
|
||||
|
||||
function titleFor(asset) {
|
||||
return asset.name || (config ? config.label : 'Asset')
|
||||
}
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (selected.value.length === 0) return 0
|
||||
const skipped = parseInt(startCell.value) - 1
|
||||
if (format.value === 'uline6') {
|
||||
return Math.ceil((selected.value.length + skipped) / CELLS_PER_PAGE)
|
||||
}
|
||||
const cells = Math.ceil(selected.value.length / MINI_PER_CELL)
|
||||
return Math.ceil((cells + skipped) / CELLS_PER_PAGE)
|
||||
})
|
||||
|
||||
// ULINE 6-up: each page is 6 cells, each holding one asset (or null).
|
||||
const uline6Pages = computed(() => {
|
||||
const pages = []
|
||||
if (selected.value.length === 0) return pages
|
||||
const skip = parseInt(startCell.value) - 1
|
||||
let idx = 0
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
|
||||
const blank = page === 0 && cell < skip
|
||||
cells.push((!blank && idx < selected.value.length) ? selected.value[idx++] : null)
|
||||
}
|
||||
pages.push(cells)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
// Mini 72-up: each page is 6 cells, each holding up to 12 mini labels.
|
||||
const mini72Pages = computed(() => {
|
||||
const pages = []
|
||||
if (selected.value.length === 0) return pages
|
||||
const skip = parseInt(startCell.value) - 1
|
||||
let idx = 0
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
|
||||
const blank = (page === 0 && cell < skip) || idx >= selected.value.length
|
||||
const items = []
|
||||
if (!blank) {
|
||||
for (let mini = 0; mini < MINI_PER_CELL; mini++) {
|
||||
items.push(idx < selected.value.length ? selected.value[idx++] : null)
|
||||
}
|
||||
}
|
||||
cells.push({ hasContent: !blank, items })
|
||||
}
|
||||
pages.push(cells)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
function setBarcodeRef(el, assetid, prefix) {
|
||||
if (el) barcodeRefs.value[`${prefix}-${assetid}`] = el
|
||||
}
|
||||
|
||||
// Resolve the encode text + caption for every loaded asset once, then per
|
||||
// encode-mode change. Kept over the whole list (cheap) so selection is instant.
|
||||
async function buildCodeMap() {
|
||||
const codes = {}
|
||||
const captions = {}
|
||||
for (const asset of assets.value) {
|
||||
codes[asset.assetid] = await resolveCodeText(assettype, asset, encodes.value)
|
||||
captions[asset.assetid] = captionFor(asset, encodes.value)
|
||||
}
|
||||
codeMap.value = codes
|
||||
captionMap.value = captions
|
||||
}
|
||||
|
||||
async function buildQrMap() {
|
||||
if (codetype.value !== 'qr') return
|
||||
const images = {}
|
||||
for (const asset of selected.value) {
|
||||
const text = codeMap.value[asset.assetid]
|
||||
if (text) images[asset.assetid] = await renderQrDataUrl(text)
|
||||
}
|
||||
qrMap.value = images
|
||||
}
|
||||
|
||||
function renderBarcodes() {
|
||||
if (codetype.value !== 'barcode') return
|
||||
const prefix = format.value === 'uline6' ? 'u' : 'm'
|
||||
const height = format.value === 'uline6' ? 60 : 22
|
||||
const width = format.value === 'uline6' ? 2 : 1
|
||||
for (const asset of selected.value) {
|
||||
const el = barcodeRefs.value[`${prefix}-${asset.assetid}`]
|
||||
const text = codeMap.value[asset.assetid]
|
||||
if (!el || !text) continue
|
||||
try {
|
||||
JsBarcode(el, text, {
|
||||
format: 'CODE128', displayValue: false, width, height, margin: 0,
|
||||
background: 'transparent',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Barcode error:', asset.assetnumber, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCodes() {
|
||||
await buildCodeMap()
|
||||
await refreshRender()
|
||||
}
|
||||
|
||||
async function refreshRender() {
|
||||
await buildQrMap()
|
||||
await nextTick()
|
||||
renderBarcodes()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!config) { loadingAssets.value = false; return }
|
||||
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
||||
encodes.value = await resolveDefaultEncodes(assettype)
|
||||
try {
|
||||
const response = await config.api.list({ perpage: 500 })
|
||||
assets.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading assets:', err)
|
||||
} finally {
|
||||
loadingAssets.value = false
|
||||
await refreshCodes()
|
||||
}
|
||||
})
|
||||
|
||||
watch(encodes, refreshCodes)
|
||||
watch([selected, format, codetype, startCell], refreshRender, { deep: true })
|
||||
|
||||
function print() { window.print() }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { padding: 20px; }
|
||||
.controls {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.controls h3 { margin-top: 0; }
|
||||
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 16px; }
|
||||
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
|
||||
.control-row select { padding: 6px; font-size: 0.875rem; }
|
||||
|
||||
.asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.asset-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;
|
||||
}
|
||||
.asset-item:hover { border-color: var(--primary); }
|
||||
.asset-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.asset-item input { margin-right: 10px; }
|
||||
.asset-item label { cursor: pointer; flex: 1; }
|
||||
.asset-item .asset-sub { font-size: 11px; color: var(--text-light); }
|
||||
|
||||
.selected-count { font-weight: bold; margin: 12px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.selected-count .pages { color: var(--success); }
|
||||
|
||||
.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; }
|
||||
.secondary-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.loading-msg, .error-msg { text-align: center; padding: 1.5rem; 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; }
|
||||
|
||||
/* ULINE 6-up cells */
|
||||
.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); }
|
||||
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
|
||||
.qr-container { text-align: center; }
|
||||
.qr-img { width: 144px; height: 144px; display: block; }
|
||||
.barcode-svg { width: 2.4in; height: 0.8in; }
|
||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; color: #000; }
|
||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; text-align: center; }
|
||||
|
||||
/* Mini 72-up cells */
|
||||
.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; }
|
||||
.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; }
|
||||
.mini-qr { width: 0.48in; height: 0.48in; }
|
||||
.mini-barcode { max-width: 0.9in; height: 24px; }
|
||||
.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%;
|
||||
}
|
||||
|
||||
/* ULINE S-5627 / 6-up 3in label positions on a letter sheet */
|
||||
.pos-1, .cell-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2, .cell-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3, .cell-3 { top: 4in; left: 1.1875in; }
|
||||
.pos-4, .cell-4 { top: 4in; left: 4.3125in; }
|
||||
.pos-5, .cell-5 { top: 7.125in; left: 1.1875in; }
|
||||
.pos-6, .cell-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
@media print {
|
||||
body, .print-sheet, .label, .label-cell, .mini-label, .qr-img, .mini-qr, .barcode-svg, .mini-barcode {
|
||||
-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.inactive { visibility: hidden; }
|
||||
.label-cell { border: none !important; }
|
||||
.label-cell.empty { visibility: hidden; }
|
||||
.mini-label { border: 1px dotted #ccc !important; }
|
||||
.mini-label.empty { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
169
frontend/src/views/print/MachineBadge.vue
Normal file
169
frontend/src/views/print/MachineBadge.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div>
|
||||
<button class="print-btn" @click="print" v-if="!loading">Print Badge</button>
|
||||
<div v-if="loading" class="loading-msg">Loading...</div>
|
||||
<div v-else-if="machine" class="badge-container">
|
||||
<div class="model-name">{{ modelName }}</div>
|
||||
<img
|
||||
v-if="isInspection"
|
||||
class="machine-image"
|
||||
:src="geLogo"
|
||||
alt="GE Logo"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageUrl"
|
||||
class="machine-image"
|
||||
:src="imageUrl"
|
||||
:alt="modelName"
|
||||
/>
|
||||
<div class="barcode-container">
|
||||
<svg ref="barcodeEl"></svg>
|
||||
<div class="machine-number">*{{ machine.assetnumber }}*</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="error-msg">Machine not found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { machinesApi } from '../../api'
|
||||
import { getBadgeLogo } from '@/utils/siteSettings'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const machine = ref(null)
|
||||
const barcodeEl = ref(null)
|
||||
|
||||
const geLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
|
||||
const isInspection = computed(() => {
|
||||
if (!machine.value) return false
|
||||
return machine.value.assetnumber?.startsWith('06')
|
||||
})
|
||||
|
||||
const modelName = computed(() => {
|
||||
if (!machine.value) return ''
|
||||
if (isInspection.value) return 'Inspection'
|
||||
return machine.value.machine?.modelname || ''
|
||||
})
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
if (!machine.value) return null
|
||||
const img = machine.value.machine?.imageurl
|
||||
if (img) return img.startsWith('/') ? img : `/images/models/machines/${img}`
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
getBadgeLogo().then(logo => { geLogo.value = logo })
|
||||
try {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
machine.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading machine:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
generateBarcode()
|
||||
}
|
||||
})
|
||||
|
||||
function generateBarcode() {
|
||||
if (!barcodeEl.value || !machine.value) return
|
||||
try {
|
||||
JsBarcode(barcodeEl.value, machine.value.assetnumber, {
|
||||
format: 'CODE39',
|
||||
displayValue: false,
|
||||
width: 2,
|
||||
height: 70,
|
||||
margin: 0
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Barcode generation error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: 2.13in 3.38in; margin: 0; }
|
||||
|
||||
body { font-family: Arial, sans-serif; }
|
||||
|
||||
.badge-container {
|
||||
width: 2.13in;
|
||||
height: 3.38in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #ccc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.15in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 0.1in;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.machine-image {
|
||||
max-width: 1.8in;
|
||||
max-height: 1.5in;
|
||||
object-fit: contain;
|
||||
margin-bottom: 0.1in;
|
||||
}
|
||||
|
||||
.barcode-container {
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.barcode-container svg {
|
||||
width: 1.8in;
|
||||
height: 0.9in;
|
||||
}
|
||||
|
||||
.machine-number {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
margin-top: -0.1in;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.loading-msg, .error-msg {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print-btn { display: none; }
|
||||
.badge-container { border: none; margin: 0; }
|
||||
}
|
||||
</style>
|
||||
307
frontend/src/views/print/PrinterQRBatch.vue
Normal file
307
frontend/src/views/print/PrinterQRBatch.vue
Normal file
@@ -0,0 +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 './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>
|
||||
187
frontend/src/views/print/PrinterQRSingle.vue
Normal file
187
frontend/src/views/print/PrinterQRSingle.vue
Normal file
@@ -0,0 +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 './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>
|
||||
425
frontend/src/views/print/USBLabelBatch.vue
Normal file
425
frontend/src/views/print/USBLabelBatch.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<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>
|
||||
99
frontend/src/views/print/assetLabel.js
Normal file
99
frontend/src/views/print/assetLabel.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Shared wiring for the asset label pages (single AssetLabel.vue and batch
|
||||
// AssetLabelBatch.vue): per-type api/route config plus the "what does the code
|
||||
// encode" resolution so both surfaces behave identically.
|
||||
import {
|
||||
machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi,
|
||||
} from '../../api'
|
||||
import { fillUrlTemplate } from '@/utils/qrTarget'
|
||||
import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings'
|
||||
|
||||
// Per asset-type: which api loads it, its detail/list routes, the extension key
|
||||
// nested in the merged payload, and its qr_target_<type> settings key.
|
||||
export const TYPE_CONFIG = {
|
||||
machine: {
|
||||
api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine',
|
||||
label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines',
|
||||
},
|
||||
computer: {
|
||||
api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer',
|
||||
label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs',
|
||||
},
|
||||
printer: {
|
||||
api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer',
|
||||
label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers',
|
||||
},
|
||||
network_device: {
|
||||
api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device',
|
||||
label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network',
|
||||
},
|
||||
measuring_tool: {
|
||||
api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool',
|
||||
label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools',
|
||||
},
|
||||
}
|
||||
|
||||
// Hardcoded fallback default per type when the site setting is unset. Machines
|
||||
// encode their machine number, measuring tools their inspection location.
|
||||
const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' }
|
||||
|
||||
export function hasLocationType(assettype) {
|
||||
return assettype === 'measuring_tool'
|
||||
}
|
||||
|
||||
// The default encode mode for a type: the label_default_encodes_<type> site
|
||||
// setting when valid, else the hardcoded fallback.
|
||||
export async function resolveDefaultEncodes(assettype) {
|
||||
const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom']
|
||||
if (hasLocationType(assettype)) valid.push('location')
|
||||
const seeded = await getSetting(`label_default_encodes_${assettype}`, '')
|
||||
return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage')
|
||||
}
|
||||
|
||||
// 'location' degrades to the asset page when the asset has no location code.
|
||||
export function effectiveEncodes(encodes, asset) {
|
||||
if (encodes === 'location' && !asset?.locationcode) return 'assetpage'
|
||||
return encodes
|
||||
}
|
||||
|
||||
function tokensFor(asset) {
|
||||
return {
|
||||
assetid: asset.assetid || '',
|
||||
assetnumber: asset.assetnumber || '',
|
||||
serialnumber: asset.serialnumber || '',
|
||||
name: asset.name || '',
|
||||
pluginid: asset.pluginid || '',
|
||||
locationcode: asset.locationcode || '',
|
||||
locationname: asset.locationname || '',
|
||||
}
|
||||
}
|
||||
|
||||
// The string a label's code encodes for one asset. Empty when the chosen field
|
||||
// has no value (e.g. serial number on an asset with none).
|
||||
export async function resolveCodeText(assettype, asset, encodes) {
|
||||
const config = TYPE_CONFIG[assettype]
|
||||
if (!config || !asset) return ''
|
||||
const detailId = asset.pluginid
|
||||
const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}`
|
||||
switch (effectiveEncodes(encodes, asset)) {
|
||||
case 'assetnumber': return asset.assetnumber || ''
|
||||
case 'serialnumber': return asset.serialnumber || ''
|
||||
case 'location': return asset.locationcode || ''
|
||||
case 'custom': {
|
||||
const template = (await getSetting(config.targetKey, '')).trim()
|
||||
return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl
|
||||
}
|
||||
case 'assetpage':
|
||||
default: return assetPageUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Human caption printed under the code.
|
||||
export function captionFor(asset, encodes) {
|
||||
if (!asset) return ''
|
||||
switch (effectiveEncodes(encodes, asset)) {
|
||||
case 'location': return asset.locationcode || ''
|
||||
case 'serialnumber': return asset.serialnumber || ''
|
||||
case 'assetnumber':
|
||||
default: return asset.assetnumber || ''
|
||||
}
|
||||
}
|
||||
62
frontend/src/views/print/qrLogo.js
Normal file
62
frontend/src/views/print/qrLogo.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Shared GE monogram + QR-with-logo rendering for the printer QR label pages.
|
||||
// Both PrinterQRBatch and PrinterQRSingle render each QR to a data-URL image
|
||||
// (canvases print unreliably) with the GE monogram composited in the center.
|
||||
import QRCode from 'qrcode'
|
||||
import { getQrLogo } from '@/utils/siteSettings'
|
||||
|
||||
const GE_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
|
||||
|
||||
let logoImage = null
|
||||
|
||||
// Load the configured QR overlay image. On any load failure, fall back to the
|
||||
// built-in GE monogram (last-resort constant above).
|
||||
function loadLogo(url) {
|
||||
if (logoImage) return Promise.resolve(logoImage)
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
img.onload = () => { logoImage = img; resolve(img) }
|
||||
img.onerror = () => {
|
||||
const fallback = new Image()
|
||||
fallback.onload = () => { logoImage = fallback; resolve(fallback) }
|
||||
fallback.onerror = () => resolve(null)
|
||||
fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG)
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function drawLogoOverlay(canvas, logo) {
|
||||
const canvasContext = canvas.getContext('2d')
|
||||
const size = canvas.width
|
||||
const logoSize = Math.round(size * 0.22)
|
||||
const x = (size - logoSize) / 2
|
||||
const y = (size - logoSize) / 2
|
||||
|
||||
// White circle background behind the monogram
|
||||
canvasContext.beginPath()
|
||||
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
|
||||
canvasContext.fillStyle = '#fff'
|
||||
canvasContext.fill()
|
||||
|
||||
if (logo) {
|
||||
canvasContext.drawImage(logo, x, y, logoSize, logoSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Render a QR for `url` to a PNG data URL with the configured logo composited
|
||||
// in the center. Empty qr_logo setting = no overlay. Returns '' on failure.
|
||||
export async function renderQrDataUrl(url) {
|
||||
const qrLogoUrl = await getQrLogo()
|
||||
const canvas = document.createElement('canvas')
|
||||
try {
|
||||
await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' })
|
||||
if (qrLogoUrl) {
|
||||
const logo = await loadLogo(qrLogoUrl)
|
||||
drawLogoOverlay(canvas, logo)
|
||||
}
|
||||
return canvas.toDataURL('image/png')
|
||||
} catch (err) {
|
||||
console.error('QR error:', err)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user