refactor(ui): Rediseño completo de UI con Nuxt UI 4
- Nuevo layout responsivo mobile-first con tabs inferiores - Sidebar colapsable en desktop con cola de impresión - Sistema de templates reutilizables con localStorage - Soporte Dark/Light mode con UColorModeButton - Composables usePrintQueue y useTemplates para estado global - Componentes modulares: CommandBuilder, QuickActions, PrintQueue, QueueItem - Navegación por tabs: Constructor | Cola | Templates
This commit is contained in:
34
.gitignore
vendored
34
.gitignore
vendored
@@ -1,22 +1,24 @@
|
|||||||
# Dependencies
|
# Nuxt dev/build outputs
|
||||||
node_modules/
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
# Env/config
|
# Node dependencies
|
||||||
.env
|
node_modules
|
||||||
.env.*
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
npm-debug.log*
|
logs
|
||||||
yarn-debug.log*
|
*.log
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# OS
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
# Vite build output (we build to public/)
|
# Local env files
|
||||||
public/assets/
|
.env
|
||||||
public/*.map
|
.env.*
|
||||||
|
!.env.example
|
||||||
# Editor
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
|
|||||||
115
README.md
115
README.md
@@ -1,60 +1,75 @@
|
|||||||
Printer Central (Epson ePOS)
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
Resumen
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
- Backend Node.js que envía comandos ePOS-Print vía SOAP a una impresora Epson configurada en HTTPS con certificado autofirmado.
|
|
||||||
- Frontend Vue 3 (Vite) con glassmorphism en escala de grises para escribir texto y activar funciones: alineación, fuente, tamaño, estilos, feed, corte, cajón, QR y código de barras. También permite componer un lote de operaciones genéricas.
|
|
||||||
|
|
||||||
Requisitos
|
## Setup
|
||||||
- Node.js 18+ (probado con v22) y npm.
|
|
||||||
|
|
||||||
Instalación y ejecución
|
Make sure to install dependencies:
|
||||||
1) Instalar dependencias:
|
|
||||||
npm install
|
|
||||||
|
|
||||||
2) Configurar destino (opcional, valores por defecto entre paréntesis):
|
```bash
|
||||||
export PRINTER_HOST=192.168.87.147 # IP/DNS de la impresora
|
# npm
|
||||||
export PRINTER_DEVICE_ID=matricial2 # DeviceId
|
npm install
|
||||||
export PRINTER_TIMEOUT_MS=60000 # Timeout ePOS
|
|
||||||
|
|
||||||
3) Desarrollo con hot reload (cliente + servidor):
|
# pnpm
|
||||||
npm run dev
|
pnpm install
|
||||||
|
|
||||||
- Frontend (Vite + Vue 3) en http://localhost:5173
|
# yarn
|
||||||
- Proxy /api → http://localhost:3030 (Express)
|
yarn install
|
||||||
- El servidor se recarga con nodemon al cambiar archivos en src/
|
|
||||||
|
|
||||||
4) Producción (build del cliente + servidor Express):
|
# bun
|
||||||
npm run build # genera assets en public/
|
bun install
|
||||||
npm start # sirve API y estáticos desde public/ (por defecto en :3030)
|
```
|
||||||
|
|
||||||
API principal
|
## Development Server
|
||||||
- POST /api/print
|
|
||||||
Body JSON: { operations: [ { op: string, ...attrs }, ... ] }
|
|
||||||
Construye el cuerpo de <epos-print> según las operaciones y lo envía dentro de un sobre SOAP a:
|
|
||||||
https://PRINTER_HOST/cgi-bin/epos/service.cgi?devid=PRINTER_DEVICE_ID&timeout=PRINTER_TIMEOUT_MS
|
|
||||||
|
|
||||||
Operaciones soportadas (clave op)
|
Start the development server on `http://localhost:3000`:
|
||||||
- text { value }
|
|
||||||
- textLang { lang }
|
|
||||||
- textAlign { align: left|center|right }
|
|
||||||
- textRotate { rotate: boolean }
|
|
||||||
- textLineSpace { linespc }
|
|
||||||
- textFont { font: font_a|font_b|... }
|
|
||||||
- textSmooth { smooth: boolean }
|
|
||||||
- textDouble { dw: boolean, dh: boolean }
|
|
||||||
- textSize { width: 1..8, height: 1..8 }
|
|
||||||
- textStyle { reverse?: boolean, ul?: boolean, em?: boolean, color?: color_1..color_4 }
|
|
||||||
- textPosition { x }
|
|
||||||
- textVPosition { y }
|
|
||||||
- feed {}
|
|
||||||
- feedUnit { unit }
|
|
||||||
- feedLine { line }
|
|
||||||
- feedPosition { pos }
|
|
||||||
- cut { type: no_feed|feed|reserve|no_feed_fullcut|feed_fullcut|reserve_fullcut }
|
|
||||||
- pulse { drawer?: drawer_1|drawer_2, time?: pulse_100|...|pulse_500 }
|
|
||||||
- barcode { data, type, hri, font, width, height }
|
|
||||||
- qrcode { data, model, level, size }
|
|
||||||
|
|
||||||
Notas
|
```bash
|
||||||
- El backend desactiva la verificación TLS (self-signed) únicamente para la conexión con la impresora.
|
# npm
|
||||||
- Si necesitas más funciones (logo/imágenes, páginas, líneas, etc.), se pueden añadir fácilmente replicando los métodos del ePOSBuilder oficial.
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
|
|||||||
12
app/app.config.ts
Normal file
12
app/app.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'zinc',
|
||||||
|
secondary: 'slate',
|
||||||
|
success: 'emerald',
|
||||||
|
info: 'sky',
|
||||||
|
warning: 'amber',
|
||||||
|
error: 'rose'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<NuxtPage />
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
34
app/assets/css/main.css
Normal file
34
app/assets/css/main.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/* Safe area para PWA en iOS */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Padding inferior para contenido con navegación mobile */
|
||||||
|
.pb-mobile-nav {
|
||||||
|
padding-bottom: calc(4rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar personalizado */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-400 dark:bg-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset básico */
|
||||||
|
html, body, #__nuxt {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
187
app/components/constructor/CommandBuilder.vue
Normal file
187
app/components/constructor/CommandBuilder.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const queue = usePrintQueue()
|
||||||
|
|
||||||
|
// Estado del formulario
|
||||||
|
const text = ref('')
|
||||||
|
const align = ref('')
|
||||||
|
const font = ref('')
|
||||||
|
const width = ref<number | undefined>()
|
||||||
|
const height = ref<number | undefined>()
|
||||||
|
const bold = ref(false)
|
||||||
|
const underline = ref(false)
|
||||||
|
const reverse = ref(false)
|
||||||
|
const smooth = ref(false)
|
||||||
|
const color = ref('')
|
||||||
|
const feedLines = ref<number | undefined>()
|
||||||
|
const cut = ref('')
|
||||||
|
|
||||||
|
// Opciones para selects
|
||||||
|
const alignOptions = [
|
||||||
|
{ label: '(sin cambio)', value: '' },
|
||||||
|
{ label: 'Izquierda', value: 'left' },
|
||||||
|
{ label: 'Centro', value: 'center' },
|
||||||
|
{ label: 'Derecha', value: 'right' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const fontOptions = [
|
||||||
|
{ label: '(sin cambio)', value: '' },
|
||||||
|
{ label: 'Font A', value: 'font_a' },
|
||||||
|
{ label: 'Font B', value: 'font_b' },
|
||||||
|
{ label: 'Font C', value: 'font_c' },
|
||||||
|
{ label: 'Font D', value: 'font_d' },
|
||||||
|
{ label: 'Font E', value: 'font_e' },
|
||||||
|
{ label: 'Special A', value: 'special_a' },
|
||||||
|
{ label: 'Special B', value: 'special_b' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const colorOptions = [
|
||||||
|
{ label: '(default)', value: '' },
|
||||||
|
{ label: 'Color 1', value: 'color_1' },
|
||||||
|
{ label: 'Color 2', value: 'color_2' },
|
||||||
|
{ label: 'Color 3', value: 'color_3' },
|
||||||
|
{ label: 'Color 4', value: 'color_4' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const cutOptions = [
|
||||||
|
{ label: '(no cortar)', value: '' },
|
||||||
|
{ label: 'Sin feed', value: 'no_feed' },
|
||||||
|
{ label: 'Con feed', value: 'feed' },
|
||||||
|
{ label: 'Reservar', value: 'reserve' },
|
||||||
|
{ label: 'Full sin feed', value: 'no_feed_fullcut' },
|
||||||
|
{ label: 'Full con feed', value: 'feed_fullcut' },
|
||||||
|
{ label: 'Full reservar', value: 'reserve_fullcut' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatSections = [
|
||||||
|
{ label: 'Alineación y Fuente', value: 'alignment', icon: 'i-heroicons-bars-3-bottom-left' },
|
||||||
|
{ label: 'Tamaño', value: 'size', icon: 'i-heroicons-arrows-pointing-out' },
|
||||||
|
{ label: 'Estilo', value: 'style', icon: 'i-heroicons-paint-brush' },
|
||||||
|
{ label: 'Acciones finales', value: 'actions', icon: 'i-heroicons-scissors' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
text.value = ''
|
||||||
|
align.value = ''
|
||||||
|
font.value = ''
|
||||||
|
width.value = undefined
|
||||||
|
height.value = undefined
|
||||||
|
bold.value = false
|
||||||
|
underline.value = false
|
||||||
|
reverse.value = false
|
||||||
|
smooth.value = false
|
||||||
|
color.value = ''
|
||||||
|
feedLines.value = undefined
|
||||||
|
cut.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueText() {
|
||||||
|
const ops: any[] = []
|
||||||
|
|
||||||
|
if (align.value) ops.push({ op: 'textAlign', align: align.value })
|
||||||
|
if (font.value) ops.push({ op: 'textFont', font: font.value })
|
||||||
|
if (width.value || height.value) {
|
||||||
|
ops.push({ op: 'textSize', width: width.value, height: height.value })
|
||||||
|
}
|
||||||
|
ops.push({
|
||||||
|
op: 'textStyle',
|
||||||
|
em: bold.value,
|
||||||
|
ul: underline.value,
|
||||||
|
reverse: reverse.value,
|
||||||
|
...(color.value ? { color: color.value } : {})
|
||||||
|
})
|
||||||
|
if (text.value) ops.push({ op: 'text', value: text.value })
|
||||||
|
if (feedLines.value != null && feedLines.value !== 0) {
|
||||||
|
ops.push({ op: 'feedLine', line: Number(feedLines.value) })
|
||||||
|
}
|
||||||
|
if (cut.value) ops.push({ op: 'cut', type: cut.value })
|
||||||
|
|
||||||
|
queue.addOperations(ops)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Constructor de Comandos
|
||||||
|
</h2>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-arrow-path"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="resetForm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Textarea principal -->
|
||||||
|
<UFormField label="Texto a imprimir" class="mb-4">
|
||||||
|
<UTextarea
|
||||||
|
v-model="text"
|
||||||
|
:rows="4"
|
||||||
|
autoresize
|
||||||
|
placeholder="Escribe el texto a imprimir..."
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Controles de formato con Accordion -->
|
||||||
|
<UAccordion :items="formatSections" type="multiple">
|
||||||
|
<template #alignment>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 p-3">
|
||||||
|
<UFormField label="Alineación">
|
||||||
|
<USelect v-model="align" :items="alignOptions" placeholder="Seleccionar" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Fuente">
|
||||||
|
<USelect v-model="font" :items="fontOptions" placeholder="Seleccionar" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Color">
|
||||||
|
<USelect v-model="color" :items="colorOptions" placeholder="Seleccionar" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #size>
|
||||||
|
<div class="grid grid-cols-2 gap-3 p-3">
|
||||||
|
<UFormField label="Ancho (1-8)">
|
||||||
|
<UInput v-model.number="width" type="number" :min="1" :max="8" placeholder="1" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Alto (1-8)">
|
||||||
|
<UInput v-model.number="height" type="number" :min="1" :max="8" placeholder="1" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #style>
|
||||||
|
<div class="flex flex-wrap gap-4 p-3">
|
||||||
|
<UCheckbox v-model="bold" label="Negrita" />
|
||||||
|
<UCheckbox v-model="underline" label="Subrayado" />
|
||||||
|
<UCheckbox v-model="reverse" label="Invertido" />
|
||||||
|
<UCheckbox v-model="smooth" label="Suavizado" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<div class="grid grid-cols-2 gap-3 p-3">
|
||||||
|
<UFormField label="Líneas de feed">
|
||||||
|
<UInput v-model.number="feedLines" type="number" :min="0" :max="255" placeholder="0" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Corte de papel">
|
||||||
|
<USelect v-model="cut" :items="cutOptions" placeholder="Seleccionar" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton color="primary" @click="queueText">
|
||||||
|
Agregar a cola
|
||||||
|
</UButton>
|
||||||
|
<UButton variant="ghost" @click="resetForm">
|
||||||
|
Limpiar formulario
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
54
app/components/constructor/QuickActions.vue
Normal file
54
app/components/constructor/QuickActions.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const queue = usePrintQueue()
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{
|
||||||
|
label: 'Feed 2',
|
||||||
|
icon: 'i-heroicons-arrow-down',
|
||||||
|
ops: [{ op: 'feedLine', line: 2 }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cortar',
|
||||||
|
icon: 'i-heroicons-scissors',
|
||||||
|
ops: [{ op: 'cut', type: 'feed' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pulse',
|
||||||
|
icon: 'i-heroicons-bolt',
|
||||||
|
ops: [{ op: 'pulse', drawer: 'drawer_1', time: 'pulse_200' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'QR',
|
||||||
|
icon: 'i-heroicons-qr-code',
|
||||||
|
ops: [{ op: 'qrcode', data: 'https://example.com', model: 'qrcode_model_2', level: 'level_m', size: 6 }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barcode',
|
||||||
|
icon: 'i-heroicons-bars-3',
|
||||||
|
ops: [{ op: 'barcode', data: '490123456789', type: 'ean13', hri: 'below', width: 3, height: 80 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard variant="soft">
|
||||||
|
<template #header>
|
||||||
|
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Atajos rápidos
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
v-for="action in quickActions"
|
||||||
|
:key="action.label"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:icon="action.icon"
|
||||||
|
@click="queue.addOperations(action.ops)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
31
app/components/layout/AppHeader.vue
Normal file
31
app/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const queue = usePrintQueue()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
PrinterCentral
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Control de impresoras Epson ePOS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Contador de cola (visible en desktop) -->
|
||||||
|
<UBadge
|
||||||
|
v-if="queue.operations.value.length > 0"
|
||||||
|
color="primary"
|
||||||
|
variant="subtle"
|
||||||
|
class="hidden md:flex"
|
||||||
|
>
|
||||||
|
{{ queue.operations.value.length }} en cola
|
||||||
|
</UBadge>
|
||||||
|
|
||||||
|
<!-- Toggle Dark/Light mode -->
|
||||||
|
<UColorModeButton />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
40
app/components/layout/MobileNavigation.vue
Normal file
40
app/components/layout/MobileNavigation.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const activeTab = defineModel<string>({ default: 'constructor' })
|
||||||
|
|
||||||
|
const queue = usePrintQueue()
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Constructor',
|
||||||
|
value: 'constructor',
|
||||||
|
icon: 'i-heroicons-pencil-square'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Cola (${queue.operations.value.length})`,
|
||||||
|
value: 'queue',
|
||||||
|
icon: 'i-heroicons-queue-list'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Templates',
|
||||||
|
value: 'templates',
|
||||||
|
icon: 'i-heroicons-document-duplicate'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="fixed bottom-0 left-0 right-0 bg-white/95 dark:bg-gray-950/95 backdrop-blur border-t border-gray-200 dark:border-gray-800 md:hidden z-50 safe-area-bottom">
|
||||||
|
<UTabs
|
||||||
|
v-model="activeTab"
|
||||||
|
:items="tabs"
|
||||||
|
variant="pill"
|
||||||
|
color="neutral"
|
||||||
|
:content="false"
|
||||||
|
class="justify-center py-2"
|
||||||
|
:ui="{
|
||||||
|
list: 'justify-center gap-1',
|
||||||
|
trigger: 'px-3 py-2'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
47
app/components/queue/PrintQueue.vue
Normal file
47
app/components/queue/PrintQueue.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const queue = usePrintQueue()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-if="queue.operations.value.length === 0" class="text-center py-8">
|
||||||
|
<UIcon name="i-heroicons-queue-list" class="w-12 h-12 text-gray-400 dark:text-gray-600 mx-auto mb-2" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
|
No hay comandos en la cola
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
Usa el constructor para agregar comandos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransitionGroup name="list" tag="div" class="space-y-2">
|
||||||
|
<QueueQueueItem
|
||||||
|
v-for="(op, index) in queue.operations.value"
|
||||||
|
:key="index"
|
||||||
|
:operation="op"
|
||||||
|
:index="index"
|
||||||
|
:is-first="index === 0"
|
||||||
|
:is-last="index === queue.operations.value.length - 1"
|
||||||
|
@update="(newOp) => queue.updateOperation(index, newOp)"
|
||||||
|
@remove="queue.removeOperation(index)"
|
||||||
|
@move-up="queue.moveOperation(index, 'up')"
|
||||||
|
@move-down="queue.moveOperation(index, 'down')"
|
||||||
|
/>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-enter-active,
|
||||||
|
.list-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.list-enter-from,
|
||||||
|
.list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
|
.list-move {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
111
app/components/queue/QueueActions.vue
Normal file
111
app/components/queue/QueueActions.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const queue = usePrintQueue()
|
||||||
|
const templates = useTemplates()
|
||||||
|
|
||||||
|
const saveDrawerOpen = ref(false)
|
||||||
|
const templateName = ref('')
|
||||||
|
const templateDescription = ref('')
|
||||||
|
|
||||||
|
function saveAsTemplate() {
|
||||||
|
if (!templateName.value.trim()) return
|
||||||
|
templates.saveTemplate(
|
||||||
|
templateName.value.trim(),
|
||||||
|
templateDescription.value.trim(),
|
||||||
|
queue.operations.value as any
|
||||||
|
)
|
||||||
|
templateName.value = ''
|
||||||
|
templateDescription.value = ''
|
||||||
|
saveDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Resultado de la última impresión -->
|
||||||
|
<UCard v-if="queue.result.value" variant="soft" :class="queue.result.value.ok ? '' : 'border-red-500'">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UIcon
|
||||||
|
:name="queue.result.value.ok ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-circle'"
|
||||||
|
:class="queue.result.value.ok ? 'text-green-500' : 'text-red-500'"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">
|
||||||
|
{{ queue.result.value.ok ? (queue.result.value.msg || 'Listo') : (queue.result.value.error || 'Error') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre v-if="queue.result.value.code" class="text-xs text-gray-500 mt-2 overflow-auto">{{ queue.result.value.code }}</pre>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Botones de acción -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
:loading="queue.loading.value"
|
||||||
|
:disabled="queue.operations.value.length === 0"
|
||||||
|
@click="queue.sendToPrinter"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-heroicons-printer" class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
Imprimir ({{ queue.operations.value.length }})
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
variant="outline"
|
||||||
|
:disabled="queue.operations.value.length === 0"
|
||||||
|
@click="saveDrawerOpen = true"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-heroicons-bookmark" class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
Guardar template
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
color="error"
|
||||||
|
:disabled="queue.operations.value.length === 0"
|
||||||
|
@click="queue.clearQueue"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
Limpiar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer para guardar template -->
|
||||||
|
<UDrawer v-model:open="saveDrawerOpen" direction="bottom" title="Guardar como Template">
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<UFormField label="Nombre del template" required>
|
||||||
|
<UInput v-model="templateName" placeholder="Ej: Ticket de venta" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Descripción (opcional)">
|
||||||
|
<UTextarea
|
||||||
|
v-model="templateDescription"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="Breve descripción del template..."
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Se guardarán {{ queue.operations.value.length }} comandos de la cola actual.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2 justify-end p-4">
|
||||||
|
<UButton variant="ghost" @click="saveDrawerOpen = false">
|
||||||
|
Cancelar
|
||||||
|
</UButton>
|
||||||
|
<UButton color="primary" :disabled="!templateName.trim()" @click="saveAsTemplate">
|
||||||
|
Guardar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
183
app/components/queue/QueueItem.vue
Normal file
183
app/components/queue/QueueItem.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Operation } from '~/composables/usePrintQueue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
operation: Operation
|
||||||
|
index: number
|
||||||
|
isFirst: boolean
|
||||||
|
isLast: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [op: Operation]
|
||||||
|
remove: []
|
||||||
|
'move-up': []
|
||||||
|
'move-down': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editableFields = reactive<Record<string, string>>({})
|
||||||
|
|
||||||
|
// Color según tipo de operación
|
||||||
|
const opColor = computed(() => {
|
||||||
|
const op = props.operation.op
|
||||||
|
if (op.startsWith('text')) return 'primary'
|
||||||
|
if (op.startsWith('feed')) return 'info'
|
||||||
|
if (op === 'cut') return 'warning'
|
||||||
|
if (op === 'pulse') return 'success'
|
||||||
|
if (op === 'barcode' || op === 'qrcode') return 'secondary'
|
||||||
|
return 'neutral'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Icono según tipo de operación
|
||||||
|
const opIcon = computed(() => {
|
||||||
|
const op = props.operation.op
|
||||||
|
if (op === 'text') return 'i-heroicons-document-text'
|
||||||
|
if (op.startsWith('text')) return 'i-heroicons-adjustments-horizontal'
|
||||||
|
if (op.startsWith('feed')) return 'i-heroicons-arrow-down'
|
||||||
|
if (op === 'cut') return 'i-heroicons-scissors'
|
||||||
|
if (op === 'pulse') return 'i-heroicons-bolt'
|
||||||
|
if (op === 'barcode') return 'i-heroicons-bars-3'
|
||||||
|
if (op === 'qrcode') return 'i-heroicons-qr-code'
|
||||||
|
return 'i-heroicons-cog-6-tooth'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inicializar campos editables
|
||||||
|
function initEditableFields() {
|
||||||
|
Object.keys(editableFields).forEach(k => delete editableFields[k])
|
||||||
|
for (const [k, v] of Object.entries(props.operation)) {
|
||||||
|
if (k === 'op') continue
|
||||||
|
editableFields[k] = typeof v === 'object' ? JSON.stringify(v) : String(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.operation, initEditableFields, { immediate: true, deep: true })
|
||||||
|
|
||||||
|
function applyChanges() {
|
||||||
|
const updated: Operation = { op: props.operation.op }
|
||||||
|
for (const [k, v] of Object.entries(editableFields)) {
|
||||||
|
try {
|
||||||
|
updated[k] = JSON.parse(v)
|
||||||
|
} catch {
|
||||||
|
const n = Number(v)
|
||||||
|
updated[k] = isNaN(n) ? v : n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('update', updated)
|
||||||
|
isEditing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
initEditableFields()
|
||||||
|
isEditing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resumen legible del comando
|
||||||
|
const summary = computed(() => {
|
||||||
|
const op = props.operation
|
||||||
|
switch (op.op) {
|
||||||
|
case 'text':
|
||||||
|
return op.value?.substring(0, 30) + (op.value?.length > 30 ? '...' : '')
|
||||||
|
case 'textAlign':
|
||||||
|
return `Alinear: ${op.align}`
|
||||||
|
case 'textFont':
|
||||||
|
return `Fuente: ${op.font}`
|
||||||
|
case 'textSize':
|
||||||
|
return `Tamaño: ${op.width || 1}x${op.height || 1}`
|
||||||
|
case 'textStyle':
|
||||||
|
const styles = []
|
||||||
|
if (op.em) styles.push('negrita')
|
||||||
|
if (op.ul) styles.push('subrayado')
|
||||||
|
if (op.reverse) styles.push('invertido')
|
||||||
|
return styles.length ? `Estilo: ${styles.join(', ')}` : 'Estilo: normal'
|
||||||
|
case 'feedLine':
|
||||||
|
return `Feed: ${op.line} líneas`
|
||||||
|
case 'cut':
|
||||||
|
return `Cortar: ${op.type}`
|
||||||
|
case 'pulse':
|
||||||
|
return `Pulse: ${op.drawer}`
|
||||||
|
case 'qrcode':
|
||||||
|
return `QR: ${op.data?.substring(0, 20)}...`
|
||||||
|
case 'barcode':
|
||||||
|
return `Barcode: ${op.data}`
|
||||||
|
default:
|
||||||
|
return op.op
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard variant="soft" class="group">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Indicador de tipo -->
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<UBadge :color="opColor" variant="subtle" size="xs">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</UBadge>
|
||||||
|
<UIcon :name="opIcon" class="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenido -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-medium text-sm text-gray-900 dark:text-white">
|
||||||
|
{{ operation.op }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vista normal -->
|
||||||
|
<p v-if="!isEditing" class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{{ summary }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Vista edición -->
|
||||||
|
<div v-else class="space-y-2 mt-2">
|
||||||
|
<div v-for="(value, key) in editableFields" :key="key" class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 w-16 shrink-0">{{ key }}:</span>
|
||||||
|
<UInput
|
||||||
|
v-model="editableFields[key]"
|
||||||
|
size="xs"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<UButton size="xs" @click="applyChanges">Aplicar</UButton>
|
||||||
|
<UButton size="xs" variant="ghost" @click="cancelEdit">Cancelar</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Acciones -->
|
||||||
|
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<UButton
|
||||||
|
v-if="!isEditing"
|
||||||
|
icon="i-heroicons-pencil"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
@click="isEditing = true"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-chevron-up"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
:disabled="isFirst"
|
||||||
|
@click="$emit('move-up')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-chevron-down"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
:disabled="isLast"
|
||||||
|
@click="$emit('move-down')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-trash"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
color="error"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
80
app/components/templates/TemplateCard.vue
Normal file
80
app/components/templates/TemplateCard.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrintTemplate } from '~/composables/useTemplates'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
template: PrintTemplate
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
load: [id: string]
|
||||||
|
duplicate: [id: string]
|
||||||
|
delete: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const menuItems = computed(() => [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Duplicar',
|
||||||
|
icon: 'i-heroicons-document-duplicate',
|
||||||
|
click: () => emit('duplicate', props.template.id)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Eliminar',
|
||||||
|
icon: 'i-heroicons-trash',
|
||||||
|
color: 'error' as const,
|
||||||
|
click: () => emit('delete', props.template.id)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
function formatDate(timestamp: number) {
|
||||||
|
return new Date(timestamp).toLocaleDateString('es-AR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard variant="outline" class="hover:border-primary-500 dark:hover:border-primary-400 transition-colors">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{{ template.name }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
v-if="template.description"
|
||||||
|
class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 mt-1"
|
||||||
|
>
|
||||||
|
{{ template.description }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<UBadge variant="subtle" size="xs">
|
||||||
|
{{ template.operations.length }} comandos
|
||||||
|
</UBadge>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{{ formatDate(template.updatedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UDropdownMenu :items="menuItems">
|
||||||
|
<UButton icon="i-heroicons-ellipsis-vertical" variant="ghost" size="sm" />
|
||||||
|
</UDropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-play"
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="$emit('load', template.id)"
|
||||||
|
>
|
||||||
|
Cargar en cola
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
53
app/components/templates/TemplateList.vue
Normal file
53
app/components/templates/TemplateList.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const templates = useTemplates()
|
||||||
|
const queue = usePrintQueue()
|
||||||
|
|
||||||
|
function loadTemplate(id: string) {
|
||||||
|
const ops = templates.loadTemplate(id)
|
||||||
|
if (ops) {
|
||||||
|
queue.loadFromTemplate(ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateTemplate(id: string) {
|
||||||
|
templates.duplicateTemplate(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTemplate(id: string) {
|
||||||
|
templates.deleteTemplate(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Templates guardados
|
||||||
|
</h2>
|
||||||
|
<UBadge v-if="templates.templates.value.length > 0" variant="subtle">
|
||||||
|
{{ templates.templates.value.length }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="templates.templates.value.length === 0" class="text-center py-12">
|
||||||
|
<UIcon name="i-heroicons-document-duplicate" class="w-12 h-12 text-gray-400 dark:text-gray-600 mx-auto mb-3" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
No hay templates guardados
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
Crea comandos en el constructor y guárdalos como template desde la cola
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<TemplatesTemplateCard
|
||||||
|
v-for="template in templates.templates.value"
|
||||||
|
:key="template.id"
|
||||||
|
:template="template"
|
||||||
|
@load="loadTemplate"
|
||||||
|
@duplicate="duplicateTemplate"
|
||||||
|
@delete="deleteTemplate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
81
app/composables/usePrintQueue.ts
Normal file
81
app/composables/usePrintQueue.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
export interface Operation {
|
||||||
|
op: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrintResult {
|
||||||
|
ok: boolean
|
||||||
|
msg?: string
|
||||||
|
error?: string
|
||||||
|
code?: string
|
||||||
|
raw?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrintQueue() {
|
||||||
|
const operations = useState<Operation[]>('printQueue', () => [])
|
||||||
|
const result = useState<PrintResult>('printResult', () => ({ ok: true, msg: 'Listo.' }))
|
||||||
|
const loading = useState('printLoading', () => false)
|
||||||
|
|
||||||
|
function addOperations(ops: Operation[]) {
|
||||||
|
if (Array.isArray(ops) && ops.length) {
|
||||||
|
operations.value = [...operations.value, ...ops]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOperation(index: number, op: Operation) {
|
||||||
|
operations.value = operations.value.map((o, i) => i === index ? op : o)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOperation(index: number) {
|
||||||
|
operations.value = operations.value.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveOperation(from: number, direction: 'up' | 'down') {
|
||||||
|
const to = direction === 'up' ? from - 1 : from + 1
|
||||||
|
if (to < 0 || to >= operations.value.length) return
|
||||||
|
|
||||||
|
const arr = [...operations.value]
|
||||||
|
;[arr[from], arr[to]] = [arr[to], arr[from]]
|
||||||
|
operations.value = arr
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueue() {
|
||||||
|
operations.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromTemplate(ops: Operation[]) {
|
||||||
|
operations.value = JSON.parse(JSON.stringify(ops))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendToPrinter() {
|
||||||
|
if (operations.value.length === 0) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
result.value = await $fetch('/api/print', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { operations: operations.value }
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
result.value = {
|
||||||
|
ok: false,
|
||||||
|
error: error.message || 'Error al enviar a la impresora'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
operations: readonly(operations),
|
||||||
|
result: readonly(result),
|
||||||
|
loading: readonly(loading),
|
||||||
|
addOperations,
|
||||||
|
updateOperation,
|
||||||
|
removeOperation,
|
||||||
|
moveOperation,
|
||||||
|
clearQueue,
|
||||||
|
loadFromTemplate,
|
||||||
|
sendToPrinter
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/composables/useTemplates.ts
Normal file
87
app/composables/useTemplates.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Operation } from './usePrintQueue'
|
||||||
|
|
||||||
|
export interface PrintTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
operations: Operation[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'printercentral-templates'
|
||||||
|
|
||||||
|
export function useTemplates() {
|
||||||
|
const templates = useState<PrintTemplate[]>('templates', () => [])
|
||||||
|
const initialized = useState('templatesInitialized', () => false)
|
||||||
|
|
||||||
|
// Cargar de localStorage al iniciar (solo cliente)
|
||||||
|
if (import.meta.client && !initialized.value) {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
templates.value = JSON.parse(stored)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing templates:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialized.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar en localStorage cuando cambie
|
||||||
|
watch(templates, (val) => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
function saveTemplate(name: string, description: string, operations: Operation[]): PrintTemplate {
|
||||||
|
const template: PrintTemplate = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
operations: JSON.parse(JSON.stringify(operations)),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
templates.value = [...templates.value, template]
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTemplate(id: string, updates: Partial<Omit<PrintTemplate, 'id' | 'createdAt'>>) {
|
||||||
|
const idx = templates.value.findIndex(t => t.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
templates.value = templates.value.map((t, i) =>
|
||||||
|
i === idx ? { ...t, ...updates, updatedAt: Date.now() } : t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTemplate(id: string) {
|
||||||
|
templates.value = templates.value.filter(t => t.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTemplate(id: string): Operation[] | null {
|
||||||
|
const template = templates.value.find(t => t.id === id)
|
||||||
|
return template ? JSON.parse(JSON.stringify(template.operations)) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateTemplate(id: string): PrintTemplate | null {
|
||||||
|
const template = templates.value.find(t => t.id === id)
|
||||||
|
if (!template) return null
|
||||||
|
return saveTemplate(
|
||||||
|
`${template.name} (copia)`,
|
||||||
|
template.description || '',
|
||||||
|
template.operations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates: readonly(templates),
|
||||||
|
saveTemplate,
|
||||||
|
updateTemplate,
|
||||||
|
deleteTemplate,
|
||||||
|
loadTemplate,
|
||||||
|
duplicateTemplate
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/pages/index.vue
Normal file
60
app/pages/index.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const activeTab = ref('constructor')
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||||
|
const queue = usePrintQueue()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<!-- Header -->
|
||||||
|
<LayoutAppHeader />
|
||||||
|
|
||||||
|
<!-- Layout principal -->
|
||||||
|
<div class="flex h-[calc(100vh-73px)]">
|
||||||
|
<!-- Sidebar solo en desktop -->
|
||||||
|
<aside
|
||||||
|
v-if="isDesktop"
|
||||||
|
class="w-80 border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex flex-col"
|
||||||
|
>
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<h2 class="font-semibold text-gray-900 dark:text-white">Cola de Impresión</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ queue.operations.value.length }} comandos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
<QueuePrintQueue />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-800">
|
||||||
|
<QueueQueueActions />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Panel principal -->
|
||||||
|
<main class="flex-1 overflow-y-auto pb-mobile-nav md:pb-0">
|
||||||
|
<div class="max-w-3xl mx-auto p-4">
|
||||||
|
<!-- En mobile: mostrar según tab activo -->
|
||||||
|
<!-- En desktop: siempre mostrar constructor -->
|
||||||
|
<template v-if="isDesktop || activeTab === 'constructor'">
|
||||||
|
<ConstructorCommandBuilder />
|
||||||
|
<ConstructorQuickActions class="mt-4" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeTab === 'queue'">
|
||||||
|
<QueuePrintQueue />
|
||||||
|
<QueueQueueActions class="mt-4" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeTab === 'templates'">
|
||||||
|
<TemplatesTemplateList />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navegación mobile -->
|
||||||
|
<LayoutMobileNavigation v-model="activeTab" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
13
index.html
13
index.html
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Printer Central</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/client/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
121
nuxt.config.ts
Normal file
121
nuxt.config.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: [
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@vite-pwa/nuxt'
|
||||||
|
],
|
||||||
|
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
colorMode: {
|
||||||
|
classSuffix: '',
|
||||||
|
preference: 'dark',
|
||||||
|
fallback: 'dark'
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
// Variables privadas del servidor (no expuestas al cliente)
|
||||||
|
printerHost: process.env.PRINTER_HOST || '192.168.87.147',
|
||||||
|
printerDeviceId: process.env.PRINTER_DEVICE_ID || 'matricial2',
|
||||||
|
printerTimeoutMs: process.env.PRINTER_TIMEOUT_MS || '60000',
|
||||||
|
|
||||||
|
public: {
|
||||||
|
// Variables públicas (expuestas al cliente)
|
||||||
|
authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com',
|
||||||
|
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pwa: {
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'icon.svg', 'offline.html'],
|
||||||
|
manifest: {
|
||||||
|
name: 'PrinterCentral - Control de Impresoras Epson',
|
||||||
|
short_name: 'PrinterCentral',
|
||||||
|
description: 'Aplicación para controlar impresoras Epson ePOS vía protocolo SOAP/XML',
|
||||||
|
theme_color: '#1a1a1a',
|
||||||
|
background_color: '#0a0a0a',
|
||||||
|
display: 'standalone',
|
||||||
|
display_override: ['window-controls-overlay'],
|
||||||
|
orientation: 'portrait',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512x512-maskable.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/offline.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api\//],
|
||||||
|
globPatterns: ['**/*.{js,css,html,png,svg,ico,json,jpeg}'],
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// API calls siempre van a la red
|
||||||
|
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
||||||
|
handler: 'NetworkOnly'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Páginas con NetworkFirst
|
||||||
|
urlPattern: ({ url, request }) => {
|
||||||
|
return request.destination === 'document' ||
|
||||||
|
request.mode === 'navigate' ||
|
||||||
|
url.pathname === '/'
|
||||||
|
},
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'pages-cache',
|
||||||
|
networkTimeoutSeconds: 3,
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 7 // 7 días
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Assets estáticos con CacheFirst
|
||||||
|
urlPattern: ({ url, request }) => {
|
||||||
|
return request.destination === 'image' ||
|
||||||
|
request.destination === 'style' ||
|
||||||
|
request.destination === 'script'
|
||||||
|
},
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'assets-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 100,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 días
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'module'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
15199
package-lock.json
generated
15199
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -1,34 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "printer-central",
|
"name": "printerCentral",
|
||||||
"version": "0.1.0",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"build": "nuxt build",
|
||||||
"dev": "concurrently -n server,client -c auto \"npm:dev:server\" \"npm:dev:client\"",
|
"dev": "nuxt dev",
|
||||||
"dev:server": "PORT=3030 nodemon --quiet --watch src --ext js src/server.js",
|
"generate": "nuxt generate",
|
||||||
"dev:client": "vite",
|
"preview": "nuxt preview",
|
||||||
"build": "vite build --outDir public",
|
"postinstall": "nuxt prepare"
|
||||||
"test:1": "node scripts/tests/01_text_simple.js",
|
|
||||||
"test:2": "node scripts/tests/02_align_cut.js",
|
|
||||||
"test:3": "node scripts/tests/03_font_size.js",
|
|
||||||
"test:4": "node scripts/tests/04_batch_feeds.js",
|
|
||||||
"test:5": "node scripts/tests/05_barcode.js",
|
|
||||||
"test:6": "node scripts/tests/06_qr.js",
|
|
||||||
"test:7": "node scripts/tests/07_pulse.js",
|
|
||||||
"test:all": "node scripts/test_all.js",
|
|
||||||
"print:ref": "node scripts/print_referencia.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.2",
|
"@nuxt/ui": "^4.1.0",
|
||||||
"express": "^4.19.2",
|
"@vite-pwa/nuxt": "^1.0.7",
|
||||||
"jimp": "^0.22.12"
|
"axios": "^1.13.2",
|
||||||
},
|
"jimp": "^1.6.0",
|
||||||
"devDependencies": {
|
"nuxt": "^4.2.1",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"vue": "^3.5.24",
|
||||||
"concurrently": "^9.2.1",
|
"vue-router": "^4.6.3"
|
||||||
"nodemon": "^3.1.10",
|
|
||||||
"vite": "^5.4.20",
|
|
||||||
"vue": "^3.5.22"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1,158 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Printer Central</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: system-ui, sans-serif; margin: 24px; }
|
|
||||||
fieldset { margin-bottom: 16px; }
|
|
||||||
label { display: inline-block; margin: 6px 8px 6px 0; }
|
|
||||||
input[type="text"], textarea, select { padding: 6px; }
|
|
||||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.btn { padding: 8px 12px; cursor: pointer; }
|
|
||||||
#result { white-space: pre-wrap; background: #f6f8fa; padding: 8px; border: 1px solid #ddd; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Panel de Impresión (Node → Epson ePOS)</h1>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Texto</legend>
|
|
||||||
<div class="row">
|
|
||||||
<textarea id="text" rows="4" cols="60" placeholder="Escribe el texto a imprimir..."></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Align
|
|
||||||
<select id="align">
|
|
||||||
<option value="">(sin cambio)</option>
|
|
||||||
<option value="left">left</option>
|
|
||||||
<option value="center">center</option>
|
|
||||||
<option value="right">right</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Font
|
|
||||||
<select id="font">
|
|
||||||
<option value="">(sin cambio)</option>
|
|
||||||
<option value="font_a">A</option>
|
|
||||||
<option value="font_b">B</option>
|
|
||||||
<option value="font_c">C</option>
|
|
||||||
<option value="font_d">D</option>
|
|
||||||
<option value="font_e">E</option>
|
|
||||||
<option value="special_a">Special A</option>
|
|
||||||
<option value="special_b">Special B</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Width
|
|
||||||
<input id="w" type="number" min="1" max="8" step="1" placeholder="1" style="width:5rem" />
|
|
||||||
</label>
|
|
||||||
<label>Height
|
|
||||||
<input id="h" type="number" min="1" max="8" step="1" placeholder="1" style="width:5rem" />
|
|
||||||
</label>
|
|
||||||
<label><input type="checkbox" id="bold"/> Bold</label>
|
|
||||||
<label><input type="checkbox" id="underline"/> Underline</label>
|
|
||||||
<label><input type="checkbox" id="reverse"/> Reverse</label>
|
|
||||||
<label>Smooth <input type="checkbox" id="smooth"/></label>
|
|
||||||
<label>Color
|
|
||||||
<select id="color">
|
|
||||||
<option value="">(default)</option>
|
|
||||||
<option value="color_1">1</option>
|
|
||||||
<option value="color_2">2</option>
|
|
||||||
<option value="color_3">3</option>
|
|
||||||
<option value="color_4">4</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Feed lines <input id="feedLines" type="number" min="0" max="255" step="1" style="width:5rem"/></label>
|
|
||||||
<label>Cut
|
|
||||||
<select id="cut">
|
|
||||||
<option value="">(no)</option>
|
|
||||||
<option value="no_feed">no_feed</option>
|
|
||||||
<option value="feed">feed</option>
|
|
||||||
<option value="reserve">reserve</option>
|
|
||||||
<option value="no_feed_fullcut">no_feed_fullcut</option>
|
|
||||||
<option value="feed_fullcut">feed_fullcut</option>
|
|
||||||
<option value="reserve_fullcut">reserve_fullcut</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button class="btn" id="btnPrintText">Imprimir texto</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Avanzado (componer operaciones)</legend>
|
|
||||||
<div class="row">
|
|
||||||
<button class="btn" data-op="feedLine" data-arg="line:2">Feed 2 líneas</button>
|
|
||||||
<button class="btn" data-op="cut" data-arg="type:feed">Cortar (feed)</button>
|
|
||||||
<button class="btn" id="btnDrawer">Abrir cajón (pulse)</button>
|
|
||||||
<button class="btn" id="btnQRCode">QR demo</button>
|
|
||||||
<button class="btn" id="btnBarcode">Barcode demo</button>
|
|
||||||
<button class="btn" id="btnEnviar">Enviar lote</button>
|
|
||||||
<button class="btn" id="btnLimpiar">Limpiar lote</button>
|
|
||||||
</div>
|
|
||||||
<div>Operaciones en cola:</div>
|
|
||||||
<pre id="ops"></pre>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Resultado</legend>
|
|
||||||
<pre id="result">Listo.</pre>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
const ops = [];
|
|
||||||
function refreshOps() { $('ops').textContent = JSON.stringify(ops, null, 2); }
|
|
||||||
refreshOps();
|
|
||||||
|
|
||||||
async function callApi(path, body) {
|
|
||||||
const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
|
|
||||||
const data = await res.json();
|
|
||||||
$('result').textContent = JSON.stringify(data, null, 2);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
$('btnPrintText').onclick = async () => {
|
|
||||||
const text = $('text').value;
|
|
||||||
const options = {};
|
|
||||||
const align = $('align').value; if (align) options.align = align;
|
|
||||||
const font = $('font').value; if (font) options.font = font;
|
|
||||||
const w = parseInt($('w').value || '');
|
|
||||||
const h = parseInt($('h').value || '');
|
|
||||||
if (!isNaN(w) || !isNaN(h)) options.size = { width: isNaN(w)? undefined : w, height: isNaN(h)? undefined : h };
|
|
||||||
options.style = { em: $('bold').checked, ul: $('underline').checked, reverse: $('reverse').checked };
|
|
||||||
const color = $('color').value; if (color) options.style.color = color;
|
|
||||||
if ($('smooth').checked) options.smooth = true; // optional, not wired in shortcut endpoint
|
|
||||||
const feedLines = parseInt($('feedLines').value || ''); if (!isNaN(feedLines)) options.feedLines = feedLines;
|
|
||||||
const cut = $('cut').value; if (cut) options.cut = cut;
|
|
||||||
await callApi('/api/print/text', { text, options });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Buttons to queue operations
|
|
||||||
document.querySelectorAll('[data-op]').forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
const op = btn.getAttribute('data-op');
|
|
||||||
const arg = btn.getAttribute('data-arg');
|
|
||||||
const item = { op };
|
|
||||||
if (arg) {
|
|
||||||
// parse simple key:value pairs
|
|
||||||
arg.split(',').forEach(kv => { const [k,v] = kv.split(':'); item[k.trim()] = isNaN(v) ? v : Number(v); });
|
|
||||||
}
|
|
||||||
ops.push(item); refreshOps();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
$('btnDrawer').onclick = () => { ops.push({ op: 'pulse', drawer: 'drawer_1', time: 'pulse_200' }); refreshOps(); };
|
|
||||||
$('btnQRCode').onclick = () => {
|
|
||||||
ops.push({ op: 'qrcode', data: 'https://example.com', model: 'qrcode_model_2', level: 'level_m', size: 6 });
|
|
||||||
refreshOps();
|
|
||||||
};
|
|
||||||
$('btnBarcode').onclick = () => {
|
|
||||||
ops.push({ op: 'barcode', data: '123456789012', type: 'ean13', hri: 'below', width: 3, height: 80 });
|
|
||||||
refreshOps();
|
|
||||||
};
|
|
||||||
$('btnEnviar').onclick = async () => { await callApi('/api/print', { operations: ops }); };
|
|
||||||
$('btnLimpiar').onclick = () => { ops.length = 0; refreshOps(); };
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
36
server/api/print/cut.post.ts
Normal file
36
server/api/print/cut.post.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Endpoint para cortar papel
|
||||||
|
import { buildFromOperations } from '~/server/utils/eposBuilder'
|
||||||
|
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const { type = 'feed' } = body as { type?: string }
|
||||||
|
|
||||||
|
const inner = buildFromOperations([{ op: 'cut', type }])
|
||||||
|
const soap = buildSoapEnvelope(inner)
|
||||||
|
|
||||||
|
const result = await sendToPrinter(
|
||||||
|
soap,
|
||||||
|
config.printerHost,
|
||||||
|
config.printerDeviceId,
|
||||||
|
parseInt(config.printerTimeoutMs)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { success, code } = parsePrinterResponse(result.data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: success,
|
||||||
|
httpStatus: result.status,
|
||||||
|
code,
|
||||||
|
raw: result.data
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
120
server/api/print/image.post.ts
Normal file
120
server/api/print/image.post.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Endpoint para imprimir imágenes usando Jimp
|
||||||
|
import Jimp from 'jimp'
|
||||||
|
import { EposMessageBuilder } from '~/server/utils/eposBuilder'
|
||||||
|
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const {
|
||||||
|
path,
|
||||||
|
width,
|
||||||
|
threshold = 128,
|
||||||
|
mode = 'mono'
|
||||||
|
} = body as {
|
||||||
|
path?: string
|
||||||
|
width?: number
|
||||||
|
threshold?: number
|
||||||
|
mode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'path required'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leer y procesar imagen
|
||||||
|
const img = await Jimp.read(path)
|
||||||
|
let targetWidth = width || img.bitmap.width
|
||||||
|
if (targetWidth <= 0) targetWidth = img.bitmap.width
|
||||||
|
|
||||||
|
const scale = targetWidth / img.bitmap.width
|
||||||
|
const targetHeight = Math.max(1, Math.round(img.bitmap.height * scale))
|
||||||
|
|
||||||
|
img.resize(targetWidth, targetHeight, Jimp.RESIZE_BILINEAR)
|
||||||
|
img.grayscale()
|
||||||
|
|
||||||
|
// Empaquetar bits MSB first por byte
|
||||||
|
const bytesPerRow = Math.ceil(targetWidth / 8)
|
||||||
|
const out = Buffer.alloc(bytesPerRow * targetHeight)
|
||||||
|
let outIdx = 0
|
||||||
|
|
||||||
|
for (let y = 0; y < targetHeight; y++) {
|
||||||
|
let byte = 0
|
||||||
|
let bit = 7
|
||||||
|
let rowBytes = 0
|
||||||
|
|
||||||
|
for (let x = 0; x < targetWidth; x++) {
|
||||||
|
const idx = (y * targetWidth + x) * 4
|
||||||
|
const rgba = img.bitmap.data
|
||||||
|
const r = rgba[idx]
|
||||||
|
const g = rgba[idx + 1]
|
||||||
|
const b = rgba[idx + 2]
|
||||||
|
const lum = 0.299 * r + 0.587 * g + 0.114 * b
|
||||||
|
const isBlack = lum < threshold
|
||||||
|
|
||||||
|
if (isBlack) byte |= (1 << bit)
|
||||||
|
bit--
|
||||||
|
|
||||||
|
if (bit < 0) {
|
||||||
|
out[outIdx++] = byte
|
||||||
|
rowBytes++
|
||||||
|
byte = 0
|
||||||
|
bit = 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rellenar bits restantes
|
||||||
|
if (bit !== 7) {
|
||||||
|
out[outIdx++] = byte
|
||||||
|
rowBytes++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rellenar a bytes completos si es necesario
|
||||||
|
while (rowBytes < bytesPerRow) {
|
||||||
|
out[outIdx++] = 0
|
||||||
|
rowBytes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = out.toString('base64')
|
||||||
|
|
||||||
|
// Construir mensaje ePOS
|
||||||
|
const builder = new EposMessageBuilder()
|
||||||
|
builder.imageRaw({
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
mode,
|
||||||
|
base64
|
||||||
|
})
|
||||||
|
|
||||||
|
const soap = buildSoapEnvelope(builder.build())
|
||||||
|
|
||||||
|
const result = await sendToPrinter(
|
||||||
|
soap,
|
||||||
|
config.printerHost,
|
||||||
|
config.printerDeviceId,
|
||||||
|
parseInt(config.printerTimeoutMs)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { success, code } = parsePrinterResponse(result.data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: success,
|
||||||
|
httpStatus: result.status,
|
||||||
|
code,
|
||||||
|
raw: result.data,
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
54
server/api/print/index.post.ts
Normal file
54
server/api/print/index.post.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Endpoint genérico de impresión que acepta una lista de operaciones
|
||||||
|
import { buildFromOperations, type Operation } from '~/server/utils/eposBuilder'
|
||||||
|
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const {
|
||||||
|
operations = [],
|
||||||
|
dryRun = false
|
||||||
|
} = body as {
|
||||||
|
operations?: Operation[]
|
||||||
|
dryRun?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir el XML interior con las operaciones
|
||||||
|
const inner = buildFromOperations(operations)
|
||||||
|
const soap = buildSoapEnvelope(inner)
|
||||||
|
|
||||||
|
// Si es dryRun, devolver solo el XML sin enviar a la impresora
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dryRun: true,
|
||||||
|
soap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enviar a la impresora
|
||||||
|
const result = await sendToPrinter(
|
||||||
|
soap,
|
||||||
|
config.printerHost,
|
||||||
|
config.printerDeviceId,
|
||||||
|
parseInt(config.printerTimeoutMs)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parsear la respuesta
|
||||||
|
const { success, code } = parsePrinterResponse(result.data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: success,
|
||||||
|
httpStatus: result.status,
|
||||||
|
code,
|
||||||
|
raw: result.data
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
39
server/api/print/pulse.post.ts
Normal file
39
server/api/print/pulse.post.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Endpoint para abrir cajón de dinero
|
||||||
|
import { buildFromOperations } from '~/server/utils/eposBuilder'
|
||||||
|
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const { drawer, time } = body as {
|
||||||
|
drawer?: string
|
||||||
|
time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = buildFromOperations([{ op: 'pulse', drawer, time }])
|
||||||
|
const soap = buildSoapEnvelope(inner)
|
||||||
|
|
||||||
|
const result = await sendToPrinter(
|
||||||
|
soap,
|
||||||
|
config.printerHost,
|
||||||
|
config.printerDeviceId,
|
||||||
|
parseInt(config.printerTimeoutMs)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { success, code } = parsePrinterResponse(result.data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: success,
|
||||||
|
httpStatus: result.status,
|
||||||
|
code,
|
||||||
|
raw: result.data
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
86
server/api/print/text.post.ts
Normal file
86
server/api/print/text.post.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Endpoint de conveniencia para imprimir texto con opciones
|
||||||
|
import { buildFromOperations, type Operation } from '~/server/utils/eposBuilder'
|
||||||
|
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from '~/server/utils/printer'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const {
|
||||||
|
text = '',
|
||||||
|
options = {}
|
||||||
|
} = body as {
|
||||||
|
text?: string
|
||||||
|
options?: {
|
||||||
|
align?: string
|
||||||
|
font?: string
|
||||||
|
size?: { width?: number, height?: number }
|
||||||
|
style?: {
|
||||||
|
reverse?: boolean
|
||||||
|
ul?: boolean
|
||||||
|
em?: boolean
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
feedLines?: number
|
||||||
|
cut?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir operaciones
|
||||||
|
const ops: Operation[] = []
|
||||||
|
|
||||||
|
if (options.align) {
|
||||||
|
ops.push({ op: 'textAlign', align: options.align })
|
||||||
|
}
|
||||||
|
if (options.font) {
|
||||||
|
ops.push({ op: 'textFont', font: options.font })
|
||||||
|
}
|
||||||
|
if (options.size) {
|
||||||
|
ops.push({
|
||||||
|
op: 'textSize',
|
||||||
|
width: options.size.width,
|
||||||
|
height: options.size.height
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (options.style) {
|
||||||
|
ops.push({ op: 'textStyle', ...options.style })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar el texto
|
||||||
|
ops.push({ op: 'text', value: text })
|
||||||
|
|
||||||
|
// Opciones post-texto
|
||||||
|
if (options.feedLines) {
|
||||||
|
ops.push({ op: 'feedLine', line: options.feedLines })
|
||||||
|
}
|
||||||
|
if (options.cut) {
|
||||||
|
ops.push({ op: 'cut', type: options.cut })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir SOAP y enviar
|
||||||
|
const inner = buildFromOperations(ops)
|
||||||
|
const soap = buildSoapEnvelope(inner)
|
||||||
|
|
||||||
|
const result = await sendToPrinter(
|
||||||
|
soap,
|
||||||
|
config.printerHost,
|
||||||
|
config.printerDeviceId,
|
||||||
|
parseInt(config.printerTimeoutMs)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { success, code } = parsePrinterResponse(result.data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: success,
|
||||||
|
httpStatus: result.status,
|
||||||
|
code,
|
||||||
|
raw: result.data
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
286
server/utils/eposBuilder.ts
Normal file
286
server/utils/eposBuilder.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// Server-side minimal ePOS-Print XML builder for Epson printers
|
||||||
|
// Focused on printer-only tags. Builds the inner message for <epos-print>.
|
||||||
|
|
||||||
|
// Tipos para las operaciones
|
||||||
|
export interface Operation {
|
||||||
|
op: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextStyleOptions {
|
||||||
|
reverse?: boolean
|
||||||
|
ul?: boolean
|
||||||
|
em?: boolean
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PulseOptions {
|
||||||
|
drawer?: string
|
||||||
|
time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarcodeOptions {
|
||||||
|
type?: string
|
||||||
|
hri?: string
|
||||||
|
font?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRCodeOptions {
|
||||||
|
model?: string
|
||||||
|
level?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(str: string | number): string {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EposMessageBuilder {
|
||||||
|
private parts: string[] = []
|
||||||
|
|
||||||
|
// Text and styles
|
||||||
|
text(content: string): this {
|
||||||
|
this.parts.push(`<text>${escapeXml(content)}</text>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textLang(lang: string): this {
|
||||||
|
this.parts.push(`<text lang="${lang}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textAlign(align: string): this {
|
||||||
|
this.parts.push(`<text align="${align}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textRotate(rotate: boolean): this {
|
||||||
|
this.parts.push(`<text rotate="${rotate ? 'true' : 'false'}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textLineSpace(linespc: number): this {
|
||||||
|
this.parts.push(`<text linespc="${linespc}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textFont(font: string): this {
|
||||||
|
this.parts.push(`<text font="${font}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textSmooth(smooth: boolean): this {
|
||||||
|
this.parts.push(`<text smooth="${smooth ? 'true' : 'false'}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textDouble(dw?: boolean, dh?: boolean): this {
|
||||||
|
const attrs = [
|
||||||
|
dw !== undefined ? `dw="${dw ? 'true' : 'false'}"` : '',
|
||||||
|
dh !== undefined ? `dh="${dh ? 'true' : 'false'}"` : ''
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
this.parts.push(`<text ${attrs}/>`.trim())
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textSize(width?: number, height?: number): this {
|
||||||
|
const attrs = [
|
||||||
|
width !== undefined ? `width="${width}"` : '',
|
||||||
|
height !== undefined ? `height="${height}"` : ''
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
this.parts.push(`<text ${attrs}/>`.trim())
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textStyle(options: TextStyleOptions = {}): this {
|
||||||
|
const { reverse, ul, em, color } = options
|
||||||
|
const attrs: string[] = []
|
||||||
|
if (reverse !== undefined) attrs.push(`reverse="${reverse ? 'true' : 'false'}"`)
|
||||||
|
if (ul !== undefined) attrs.push(`ul="${ul ? 'true' : 'false'}"`)
|
||||||
|
if (em !== undefined) attrs.push(`em="${em ? 'true' : 'false'}"`)
|
||||||
|
if (color !== undefined) attrs.push(`color="${color}"`)
|
||||||
|
this.parts.push(`<text ${attrs.join(' ')}/>`.trim())
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textPosition(x: number): this {
|
||||||
|
this.parts.push(`<text x="${x}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
textVPosition(y: number): this {
|
||||||
|
this.parts.push(`<text y="${y}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed
|
||||||
|
feed(): this {
|
||||||
|
this.parts.push('<feed/>')
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
feedUnit(unit: number): this {
|
||||||
|
this.parts.push(`<feed unit="${unit}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
feedLine(line: number): this {
|
||||||
|
this.parts.push(`<feed line="${line}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
feedPosition(pos: number): this {
|
||||||
|
this.parts.push(`<feed pos="${pos}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut
|
||||||
|
cut(type: string): this {
|
||||||
|
this.parts.push(`<cut type="${type}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawer
|
||||||
|
pulse(options: PulseOptions = {}): this {
|
||||||
|
const { drawer = 'drawer_1', time = 'pulse_200' } = options
|
||||||
|
this.parts.push(`<pulse drawer="${drawer}" time="${time}"/>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barcode and symbols
|
||||||
|
barcode(data: string, options: BarcodeOptions = {}): this {
|
||||||
|
const { type, hri, font, width, height } = options
|
||||||
|
const attrs: string[] = []
|
||||||
|
if (type) attrs.push(`type="${type}"`)
|
||||||
|
if (hri) attrs.push(`hri="${hri}"`)
|
||||||
|
if (font) attrs.push(`font="${font}"`)
|
||||||
|
if (width) attrs.push(`width="${width}"`)
|
||||||
|
if (height) attrs.push(`height="${height}"`)
|
||||||
|
this.parts.push(`<barcode ${attrs.join(' ')}>${escapeXml(data)}</barcode>`.trim())
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
qrcode(data: string, options: QRCodeOptions = {}): this {
|
||||||
|
const { model = 'qrcode_model_2', level = 'level_m', size } = options
|
||||||
|
const attrs = [`type="${model}"`, `level="${level}"`]
|
||||||
|
if (size) attrs.push(`size="${size}"`)
|
||||||
|
this.parts.push(`<symbol ${attrs.join(' ')}>${escapeXml(data)}</symbol>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image
|
||||||
|
imageRaw(options: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
mode?: string
|
||||||
|
base64: string
|
||||||
|
}): this {
|
||||||
|
const { width, height, mode = 'mono', base64 } = options
|
||||||
|
this.parts.push(`<image width="${width}" height="${height}" mode="${mode}">${base64}</image>`)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
build(): string {
|
||||||
|
return this.parts.join('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts a list of operations: [{ op: 'text', args: {...} | [] }, ...]
|
||||||
|
export function buildFromOperations(ops: Operation[]): string {
|
||||||
|
const b = new EposMessageBuilder()
|
||||||
|
|
||||||
|
for (const item of ops || []) {
|
||||||
|
const { op } = item
|
||||||
|
if (!op) continue
|
||||||
|
|
||||||
|
switch (op) {
|
||||||
|
case 'text':
|
||||||
|
b.text(item.value ?? '')
|
||||||
|
break
|
||||||
|
case 'textLang':
|
||||||
|
b.textLang(item.lang)
|
||||||
|
break
|
||||||
|
case 'textAlign':
|
||||||
|
b.textAlign(item.align)
|
||||||
|
break
|
||||||
|
case 'textRotate':
|
||||||
|
b.textRotate(!!item.rotate)
|
||||||
|
break
|
||||||
|
case 'textLineSpace':
|
||||||
|
b.textLineSpace(item.linespc)
|
||||||
|
break
|
||||||
|
case 'textFont':
|
||||||
|
b.textFont(item.font)
|
||||||
|
break
|
||||||
|
case 'textSmooth':
|
||||||
|
b.textSmooth(!!item.smooth)
|
||||||
|
break
|
||||||
|
case 'textDouble':
|
||||||
|
b.textDouble(item.dw, item.dh)
|
||||||
|
break
|
||||||
|
case 'textSize':
|
||||||
|
b.textSize(item.width, item.height)
|
||||||
|
break
|
||||||
|
case 'textStyle':
|
||||||
|
b.textStyle({
|
||||||
|
reverse: item.reverse,
|
||||||
|
ul: item.ul,
|
||||||
|
em: item.em,
|
||||||
|
color: item.color
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'textPosition':
|
||||||
|
b.textPosition(item.x)
|
||||||
|
break
|
||||||
|
case 'textVPosition':
|
||||||
|
b.textVPosition(item.y)
|
||||||
|
break
|
||||||
|
case 'feed':
|
||||||
|
b.feed()
|
||||||
|
break
|
||||||
|
case 'feedUnit':
|
||||||
|
b.feedUnit(item.unit)
|
||||||
|
break
|
||||||
|
case 'feedLine':
|
||||||
|
b.feedLine(item.line)
|
||||||
|
break
|
||||||
|
case 'feedPosition':
|
||||||
|
b.feedPosition(item.pos)
|
||||||
|
break
|
||||||
|
case 'cut':
|
||||||
|
b.cut(item.type || 'feed')
|
||||||
|
break
|
||||||
|
case 'pulse':
|
||||||
|
b.pulse({ drawer: item.drawer, time: item.time })
|
||||||
|
break
|
||||||
|
case 'barcode':
|
||||||
|
b.barcode(item.data || '', {
|
||||||
|
type: item.type,
|
||||||
|
hri: item.hri,
|
||||||
|
font: item.font,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'qrcode':
|
||||||
|
b.qrcode(item.data || '', {
|
||||||
|
model: item.model,
|
||||||
|
level: item.level,
|
||||||
|
size: item.size
|
||||||
|
})
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// ignore unknown for now
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.build()
|
||||||
|
}
|
||||||
62
server/utils/printer.ts
Normal file
62
server/utils/printer.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Utilidades para comunicación con impresoras Epson ePOS
|
||||||
|
import axios from 'axios'
|
||||||
|
import https from 'https'
|
||||||
|
|
||||||
|
export interface PrinterResponse {
|
||||||
|
status: number
|
||||||
|
headers: any
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSoapEnvelope(inner: string): string {
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>' +
|
||||||
|
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">' +
|
||||||
|
'<s:Body>' +
|
||||||
|
'<epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">' +
|
||||||
|
inner +
|
||||||
|
'</epos-print>' +
|
||||||
|
'</s:Body>' +
|
||||||
|
'</s:Envelope>'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendToPrinter(
|
||||||
|
xml: string,
|
||||||
|
printerHost: string,
|
||||||
|
printerDeviceId: string,
|
||||||
|
printerTimeoutMs: number
|
||||||
|
): Promise<PrinterResponse> {
|
||||||
|
const url = `https://${printerHost}/cgi-bin/epos/service.cgi?devid=${encodeURIComponent(printerDeviceId)}&timeout=${printerTimeoutMs}`
|
||||||
|
|
||||||
|
// Agente HTTPS que acepta certificados auto-firmados
|
||||||
|
const httpsAgent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await axios.post(url, xml, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/xml; charset=utf-8'
|
||||||
|
},
|
||||||
|
httpsAgent,
|
||||||
|
timeout: printerTimeoutMs + 5000,
|
||||||
|
validateStatus: () => true, // Aceptar cualquier status code
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
headers: res.headers,
|
||||||
|
data: typeof res.data === 'string' ? res.data : String(res.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePrinterResponse(responseData: string): {
|
||||||
|
success: boolean
|
||||||
|
code: string
|
||||||
|
} {
|
||||||
|
const success = /success\s*=\s*"true"/.test(responseData)
|
||||||
|
const codeMatch = responseData.match(/code="([^"]*)"/)
|
||||||
|
const code = codeMatch ? codeMatch[1] : ''
|
||||||
|
|
||||||
|
return { success, code }
|
||||||
|
}
|
||||||
@@ -1,145 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="wrap">
|
<div class="layout">
|
||||||
<header class="glass card">
|
<TopBar />
|
||||||
<h1>Printer Central</h1>
|
<MainPane :adding="adding" @add-ops="onAddOps" />
|
||||||
<p class="subtle">Vista previa y control ePOS (monocromático)</p>
|
<SidebarPane
|
||||||
</header>
|
:ops="ops"
|
||||||
|
@update-op="updateOp"
|
||||||
<main class="grid">
|
@remove-op="removeOp"
|
||||||
<section class="glass card">
|
@move-up="moveUp"
|
||||||
<h2>Texto</h2>
|
@move-down="moveDown"
|
||||||
<textarea v-model="form.text" rows="6" placeholder="Escribe el texto a imprimir..." />
|
@clear-ops="clearOps"
|
||||||
|
/>
|
||||||
<div class="controls">
|
<BottomBar :ops="ops" :result="result" @print="sendOps" />
|
||||||
<label>
|
|
||||||
Align
|
|
||||||
<select v-model="form.align">
|
|
||||||
<option value="">(sin cambio)</option>
|
|
||||||
<option value="left">left</option>
|
|
||||||
<option value="center">center</option>
|
|
||||||
<option value="right">right</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Font
|
|
||||||
<select v-model="form.font">
|
|
||||||
<option value="">(sin cambio)</option>
|
|
||||||
<option value="font_a">A</option>
|
|
||||||
<option value="font_b">B</option>
|
|
||||||
<option value="font_c">C</option>
|
|
||||||
<option value="font_d">D</option>
|
|
||||||
<option value="font_e">E</option>
|
|
||||||
<option value="special_a">Special A</option>
|
|
||||||
<option value="special_b">Special B</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Width <input type="number" min="1" max="8" v-model.number="form.w" /></label>
|
|
||||||
<label>Height <input type="number" min="1" max="8" v-model.number="form.h" /></label>
|
|
||||||
<label><input type="checkbox" v-model="form.bold" /> Bold</label>
|
|
||||||
<label><input type="checkbox" v-model="form.underline" /> Underline</label>
|
|
||||||
<label><input type="checkbox" v-model="form.reverse" /> Reverse</label>
|
|
||||||
<label>Smooth <input type="checkbox" v-model="form.smooth" /></label>
|
|
||||||
<label>
|
|
||||||
Color
|
|
||||||
<select v-model="form.color">
|
|
||||||
<option value="">(default)</option>
|
|
||||||
<option value="color_1">1</option>
|
|
||||||
<option value="color_2">2</option>
|
|
||||||
<option value="color_3">3</option>
|
|
||||||
<option value="color_4">4</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Feed lines <input type="number" min="0" max="255" v-model.number="form.feedLines" /></label>
|
|
||||||
<label>
|
|
||||||
Cut
|
|
||||||
<select v-model="form.cut">
|
|
||||||
<option value="">(no)</option>
|
|
||||||
<option value="no_feed">no_feed</option>
|
|
||||||
<option value="feed">feed</option>
|
|
||||||
<option value="reserve">reserve</option>
|
|
||||||
<option value="no_feed_fullcut">no_feed_fullcut</option>
|
|
||||||
<option value="feed_fullcut">feed_fullcut</option>
|
|
||||||
<option value="reserve_fullcut">reserve_fullcut</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn" @click="printText">Imprimir texto</button>
|
|
||||||
<button class="btn ghost" @click="resetForm">Limpiar</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="glass card">
|
|
||||||
<h2>Avanzado</h2>
|
|
||||||
<div class="chips">
|
|
||||||
<button class="chip" @click="pushOp({ op: 'feedLine', line: 2 })">Feed 2</button>
|
|
||||||
<button class="chip" @click="pushOp({ op: 'cut', type: 'feed' })">Cortar</button>
|
|
||||||
<button class="chip" @click="pushOp({ op: 'pulse', drawer: 'drawer_1', time: 'pulse_200' })">Pulse</button>
|
|
||||||
<button class="chip" @click="pushOp({ op: 'qrcode', data: 'https://example.com', model: 'qrcode_model_2', level: 'level_m', size: 6 })">QR</button>
|
|
||||||
<button class="chip" @click="pushOp({ op: 'barcode', data: '490123456789', type: 'ean13', hri: 'below', width: 3, height: 80 })">Barcode</button>
|
|
||||||
</div>
|
|
||||||
<pre class="json">{{ ops }}</pre>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn" @click="sendOps">Enviar lote</button>
|
|
||||||
<button class="btn ghost" @click="clearOps">Limpiar lote</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="glass card">
|
|
||||||
<h2>Resultado</h2>
|
|
||||||
<pre class="json result">{{ JSON.stringify(result, null, 2) }}</pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import TopBar from './components/TopBar.vue';
|
||||||
const form = reactive({
|
import MainPane from './components/MainPane.vue';
|
||||||
text: '',
|
import SidebarPane from './components/SidebarPane.vue';
|
||||||
align: '',
|
import BottomBar from './components/BottomBar.vue';
|
||||||
font: '',
|
|
||||||
w: undefined,
|
|
||||||
h: undefined,
|
|
||||||
bold: false,
|
|
||||||
underline: false,
|
|
||||||
reverse: false,
|
|
||||||
smooth: false,
|
|
||||||
color: '',
|
|
||||||
feedLines: undefined,
|
|
||||||
cut: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const ops = ref([]);
|
const ops = ref([]);
|
||||||
const result = ref({ ok: true, msg: 'Listo.' });
|
const result = ref({ ok: true, msg: 'Listo.' });
|
||||||
|
const adding = ref(false);
|
||||||
|
|
||||||
function pushOp(op) { ops.value = [...ops.value, op]; }
|
function onAddOps(newOps) { if (Array.isArray(newOps) && newOps.length) ops.value = [...ops.value, ...newOps]; }
|
||||||
function clearOps() { ops.value = []; }
|
function clearOps() { ops.value = []; }
|
||||||
function resetForm() {
|
function updateOp(index, newOp) { ops.value = ops.value.map((o, i) => (i === index ? newOp : o)); }
|
||||||
Object.assign(form, { text: '', align: '', font: '', w: undefined, h: undefined, bold: false, underline: false, reverse: false, smooth: false, color: '', feedLines: undefined, cut: '' });
|
function removeOp(index) { ops.value = ops.value.filter((_, i) => i !== index); }
|
||||||
}
|
function moveUp(index) { if (index <= 0) return; const a = [...ops.value]; [a[index - 1], a[index]] = [a[index], a[index - 1]]; ops.value = a; }
|
||||||
|
function moveDown(index) { if (index >= ops.value.length - 1) return; const a = [...ops.value]; [a[index + 1], a[index]] = [a[index], a[index + 1]]; ops.value = a; }
|
||||||
|
|
||||||
async function callApi(path, body) {
|
async function sendOps() {
|
||||||
const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
|
const res = await fetch('/api/print', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operations: ops.value }) });
|
||||||
const data = await res.json();
|
result.value = await res.json();
|
||||||
result.value = data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function printText() {
|
|
||||||
const options = {};
|
|
||||||
if (form.align) options.align = form.align;
|
|
||||||
if (form.font) options.font = form.font;
|
|
||||||
if (form.w || form.h) options.size = { width: form.w, height: form.h };
|
|
||||||
options.style = { em: form.bold, ul: form.underline, reverse: form.reverse };
|
|
||||||
if (form.color) options.style.color = form.color;
|
|
||||||
if (form.feedLines != null && form.feedLines !== '') options.feedLines = Number(form.feedLines);
|
|
||||||
if (form.cut) options.cut = form.cut;
|
|
||||||
await callApi('/api/print/text', { text: form.text, options });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendOps() { await callApi('/api/print', { operations: ops.value }); }
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -154,38 +50,29 @@ async function sendOps() { await callApi('/api/print', { operations: ops.value }
|
|||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body, #app { height: 100%; }
|
html, body, #app { height: 100%; }
|
||||||
body { margin: 0; background: linear-gradient(180deg, #0f1113 0%, #1a1d21 100%); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif; }
|
body { margin: 0; background: linear-gradient(180deg, #0f1113 0%, #1a1d21 100%); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif; }
|
||||||
.wrap { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
|
||||||
header h1 { margin: 0 0 4px; font-size: 22px; letter-spacing: 0.2px; }
|
|
||||||
.subtle { color: var(--muted); margin: 0; }
|
|
||||||
|
|
||||||
.grid { display: grid; gap: 16px; grid-template-columns: repeat(12, 1fr); margin-top: 16px; }
|
.layout {
|
||||||
section.card:nth-child(1) { grid-column: span 12; }
|
display: grid;
|
||||||
section.card:nth-child(2) { grid-column: span 12; }
|
grid-template-areas:
|
||||||
section.card:nth-child(3) { grid-column: span 12; }
|
'header'
|
||||||
@media (min-width: 860px) {
|
'main'
|
||||||
section.card:nth-child(1) { grid-column: span 7; }
|
'sidebar'
|
||||||
section.card:nth-child(2) { grid-column: span 5; }
|
'footer';
|
||||||
section.card:nth-child(3) { grid-column: span 12; }
|
grid-template-rows: auto 1fr auto auto;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 1200px; margin: 0 auto; padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass { background: var(--glass); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid var(--border); border-radius: 14px; }
|
@media (min-width: 900px) {
|
||||||
.card { padding: 16px; box-shadow: 0 8px 24px rgba(0,0,0,0.24), inset 0 1px 0 rgba(255,255,255,0.05); }
|
.layout {
|
||||||
|
grid-template-areas:
|
||||||
h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600; color: var(--accent); }
|
'header header'
|
||||||
textarea { width: 100%; color: var(--fg); background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 10px; padding: 10px; resize: vertical; }
|
'main sidebar'
|
||||||
select, input[type="number"] { color: var(--fg); background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 10px; padding: 6px 8px; }
|
'footer footer';
|
||||||
label { display: inline-flex; gap: 8px; align-items: center; margin: 6px 10px 6px 0; color: var(--muted); }
|
grid-template-rows: auto 1fr auto;
|
||||||
|
grid-template-columns: 3fr 1fr; /* 75/25 */
|
||||||
.controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
}
|
||||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
}
|
||||||
.btn { appearance: none; border: 1px solid var(--border); background: rgba(255,255,255,0.08); color: var(--fg); border-radius: 10px; padding: 8px 12px; cursor: pointer; transition: 150ms ease; }
|
|
||||||
.btn:hover { background: rgba(255,255,255,0.12); }
|
|
||||||
.btn.ghost { background: transparent; }
|
|
||||||
|
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
|
|
||||||
.chip { border: 1px dashed var(--border); background: rgba(255,255,255,0.04); color: var(--fg); border-radius: 999px; padding: 6px 10px; cursor: pointer; }
|
|
||||||
|
|
||||||
.json { background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 10px; white-space: pre-wrap; word-break: break-word; }
|
|
||||||
.result { color: #e6e6e6; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
25
src/client/components/BottomBar.vue
Normal file
25
src/client/components/BottomBar.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="foot glass card">
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" :disabled="ops.length===0" @click="$emit('print')">Enviar a imprimir ({{ ops.length }})</button>
|
||||||
|
<span class="muted">Se enviará la cola en orden.</span>
|
||||||
|
</div>
|
||||||
|
<pre class="json">{{ JSON.stringify(result, null, 2) }}</pre>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({ ops: { type: Array, required: true }, result: { type: Object, required: true } });
|
||||||
|
defineEmits(['print']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.foot { grid-area: footer; }
|
||||||
|
.glass { background: var(--glass); border: 1px solid var(--border); border-radius: 14px; padding: 12px; }
|
||||||
|
.card { box-shadow: 0 8px 24px rgba(0,0,0,0.24), inset 0 1px 0 rgba(255,255,255,0.05); }
|
||||||
|
.row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||||
|
.btn { appearance: none; border: 1px solid var(--border); background: rgba(255,255,255,0.08); color: var(--fg); border-radius: 10px; padding: 8px 12px; cursor: pointer; }
|
||||||
|
.json { background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 10px; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
|
||||||
68
src/client/components/CommandCard.vue
Normal file
68
src/client/components/CommandCard.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card glass">
|
||||||
|
<div class="row">
|
||||||
|
<strong class="op">{{ model.op }}</strong>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="icon" title="Subir" @click="$emit('move-up')">▲</button>
|
||||||
|
<button class="icon" title="Bajar" @click="$emit('move-down')">▼</button>
|
||||||
|
<button class="icon danger" title="Eliminar" @click="$emit('remove')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<template v-for="(val,key) in editable" :key="key">
|
||||||
|
<label>{{ key }}<input v-model="editable[key]" /></label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="row end">
|
||||||
|
<button class="btn tiny" @click="apply">Aplicar cambios</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
const props = defineProps({ modelValue: { type: Object, required: true } });
|
||||||
|
const emit = defineEmits(['update:modelValue','move-up','move-down','remove']);
|
||||||
|
|
||||||
|
const model = props.modelValue;
|
||||||
|
const editable = reactive({});
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
Object.keys(editable).forEach(k => delete editable[k]);
|
||||||
|
for (const [k, v] of Object.entries(model)) {
|
||||||
|
if (k === 'op') continue;
|
||||||
|
editable[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
watch(() => props.modelValue, () => refresh(), { deep: true });
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
const updated = { op: model.op };
|
||||||
|
for (const [k, v] of Object.entries(editable)) {
|
||||||
|
try {
|
||||||
|
updated[k] = JSON.parse(v);
|
||||||
|
} catch {
|
||||||
|
// numeric fallback if possible
|
||||||
|
const n = Number(v);
|
||||||
|
updated[k] = isNaN(n) ? v : n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('update:modelValue', updated);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.glass { background: var(--glass); border: 1px solid var(--border); border-radius: 12px; padding: 10px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.op { color: var(--accent); }
|
||||||
|
.icon { background: transparent; border: 1px solid var(--border); color: var(--fg); border-radius: 8px; padding: 2px 6px; cursor: pointer; }
|
||||||
|
.icon.danger { border-color: #a44; color: #e99; }
|
||||||
|
.fields { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||||
|
label { color: var(--muted); display: inline-flex; gap: 6px; align-items: center; }
|
||||||
|
input { color: var(--fg); background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 8px; padding: 4px 6px; }
|
||||||
|
.end { justify-content: flex-end; margin-top: 8px; }
|
||||||
|
.btn.tiny { font-size: 12px; padding: 6px 8px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
137
src/client/components/MainPane.vue
Normal file
137
src/client/components/MainPane.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<section class="pane glass card">
|
||||||
|
<h2>Comandos</h2>
|
||||||
|
<textarea v-model="text" rows="6" placeholder="Escribe el texto a imprimir..." />
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<label>
|
||||||
|
Align
|
||||||
|
<select v-model="align">
|
||||||
|
<option value="">(sin cambio)</option>
|
||||||
|
<option value="left">left</option>
|
||||||
|
<option value="center">center</option>
|
||||||
|
<option value="right">right</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Font
|
||||||
|
<select v-model="font">
|
||||||
|
<option value="">(sin cambio)</option>
|
||||||
|
<option value="font_a">A</option>
|
||||||
|
<option value="font_b">B</option>
|
||||||
|
<option value="font_c">C</option>
|
||||||
|
<option value="font_d">D</option>
|
||||||
|
<option value="font_e">E</option>
|
||||||
|
<option value="special_a">Special A</option>
|
||||||
|
<option value="special_b">Special B</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Width <input type="number" min="1" max="8" v-model.number="w" /></label>
|
||||||
|
<label>Height <input type="number" min="1" max="8" v-model.number="h" /></label>
|
||||||
|
<label><input type="checkbox" v-model="bold" /> Bold</label>
|
||||||
|
<label><input type="checkbox" v-model="underline" /> Underline</label>
|
||||||
|
<label><input type="checkbox" v-model="reverse" /> Reverse</label>
|
||||||
|
<label>Smooth <input type="checkbox" v-model="smooth" /></label>
|
||||||
|
<label>
|
||||||
|
Color
|
||||||
|
<select v-model="color">
|
||||||
|
<option value="">(default)</option>
|
||||||
|
<option value="color_1">1</option>
|
||||||
|
<option value="color_2">2</option>
|
||||||
|
<option value="color_3">3</option>
|
||||||
|
<option value="color_4">4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Feed lines <input type="number" min="0" max="255" v-model.number="feedLines" /></label>
|
||||||
|
<label>
|
||||||
|
Cut
|
||||||
|
<select v-model="cut">
|
||||||
|
<option value="">(no)</option>
|
||||||
|
<option value="no_feed">no_feed</option>
|
||||||
|
<option value="feed">feed</option>
|
||||||
|
<option value="reserve">reserve</option>
|
||||||
|
<option value="no_feed_fullcut">no_feed_fullcut</option>
|
||||||
|
<option value="feed_fullcut">feed_fullcut</option>
|
||||||
|
<option value="reserve_fullcut">reserve_fullcut</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" @click="queueText">Agregar a cola</button>
|
||||||
|
<button class="btn ghost" @click="resetAll">Limpiar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Atajos</h3>
|
||||||
|
<div class="chips">
|
||||||
|
<button class="chip" @click="emitOps([{ op: 'feedLine', line: 2 }])">Feed 2</button>
|
||||||
|
<button class="chip" @click="emitOps([{ op: 'cut', type: 'feed' }])">Cortar</button>
|
||||||
|
<button class="chip" @click="emitOps([{ op: 'pulse', drawer: 'drawer_1', time: 'pulse_200' }])">Pulse</button>
|
||||||
|
<button class="chip" @click="emitOps([{ op: 'qrcode', data: 'https://example.com', model: 'qrcode_model_2', level: 'level_m', size: 6 }])">QR</button>
|
||||||
|
<button class="chip" @click="emitOps([{ op: 'barcode', data: '490123456789', type: 'ean13', hri: 'below', width: 3, height: 80 }])">Barcode</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
defineProps({ adding: { type: Boolean, default: false } });
|
||||||
|
const emit = defineEmits(['add-ops']);
|
||||||
|
|
||||||
|
const text = ref('');
|
||||||
|
const align = ref('');
|
||||||
|
const font = ref('');
|
||||||
|
const w = ref();
|
||||||
|
const h = ref();
|
||||||
|
const bold = ref(false);
|
||||||
|
const underline = ref(false);
|
||||||
|
const reverse = ref(false);
|
||||||
|
const smooth = ref(false);
|
||||||
|
const color = ref('');
|
||||||
|
const feedLines = ref();
|
||||||
|
const cut = ref('');
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
text.value = '';
|
||||||
|
align.value = '';
|
||||||
|
font.value = '';
|
||||||
|
w.value = undefined; h.value = undefined;
|
||||||
|
bold.value = underline.value = reverse.value = false;
|
||||||
|
smooth.value = false; color.value = '';
|
||||||
|
feedLines.value = undefined; cut.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueText() {
|
||||||
|
const ops = [];
|
||||||
|
if (align.value) ops.push({ op: 'textAlign', align: align.value });
|
||||||
|
if (font.value) ops.push({ op: 'textFont', font: font.value });
|
||||||
|
if (w.value || h.value) ops.push({ op: 'textSize', width: w.value, height: h.value });
|
||||||
|
ops.push({ op: 'textStyle', em: bold.value, ul: underline.value, reverse: reverse.value, ...(color.value ? { color: color.value } : {}) });
|
||||||
|
if (text.value) ops.push({ op: 'text', value: text.value });
|
||||||
|
if (feedLines.value != null && feedLines.value !== '') ops.push({ op: 'feedLine', line: Number(feedLines.value) });
|
||||||
|
if (cut.value) ops.push({ op: 'cut', type: cut.value });
|
||||||
|
emit('add-ops', ops);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitOps(ops) { emit('add-ops', ops); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pane { grid-area: main; }
|
||||||
|
.glass { background: var(--glass); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid var(--border); border-radius: 14px; }
|
||||||
|
.card { padding: 16px; box-shadow: 0 8px 24px rgba(0,0,0,0.24), inset 0 1px 0 rgba(255,255,255,0.05); }
|
||||||
|
h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600; color: var(--accent); }
|
||||||
|
h3 { margin: 16px 0 8px; font-size: 14px; color: var(--muted); }
|
||||||
|
textarea { width: 100%; color: var(--fg); background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 10px; padding: 10px; resize: vertical; }
|
||||||
|
select, input[type="number"] { color: var(--fg); background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 10px; padding: 6px 8px; }
|
||||||
|
label { display: inline-flex; gap: 8px; align-items: center; margin: 6px 10px 6px 0; color: var(--muted); }
|
||||||
|
.controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
|
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||||
|
.btn { appearance: none; border: 1px solid var(--border); background: rgba(255,255,255,0.08); color: var(--fg); border-radius: 10px; padding: 8px 12px; cursor: pointer; transition: 150ms ease; }
|
||||||
|
.btn:hover { background: rgba(255,255,255,0.12); }
|
||||||
|
.btn.ghost { background: transparent; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||||
|
.chip { border: 1px dashed var(--border); background: rgba(255,255,255,0.04); color: var(--fg); border-radius: 999px; padding: 6px 10px; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
|
||||||
45
src/client/components/SidebarPane.vue
Normal file
45
src/client/components/SidebarPane.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="side">
|
||||||
|
<div class="row">
|
||||||
|
<h2>Cola de impresión</h2>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn ghost" @click="$emit('clear-ops')">Limpiar</button>
|
||||||
|
</div>
|
||||||
|
<div class="list">
|
||||||
|
<CommandCard
|
||||||
|
v-for="(op, i) in ops"
|
||||||
|
:key="i"
|
||||||
|
v-model="local[i]"
|
||||||
|
@update:modelValue="(val) => onUpdate(i, val)"
|
||||||
|
@move-up="$emit('move-up', i)"
|
||||||
|
@move-down="$emit('move-down', i)"
|
||||||
|
@remove="$emit('remove-op', i)"
|
||||||
|
/>
|
||||||
|
<p v-if="ops.length===0" class="muted">No hay comandos en la cola.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
import CommandCard from './CommandCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({ ops: { type: Array, required: true } });
|
||||||
|
const emit = defineEmits(['update-op','remove-op','move-up','move-down','clear-ops']);
|
||||||
|
|
||||||
|
const local = reactive([]);
|
||||||
|
watch(() => props.ops, (val) => { local.length = 0; val.forEach(v => local.push(v)); }, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
function onUpdate(i, val) { emit('update-op', i, val); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.side { grid-area: sidebar; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.btn { appearance: none; border: 1px solid var(--border); background: rgba(255,255,255,0.08); color: var(--fg); border-radius: 10px; padding: 6px 10px; cursor: pointer; }
|
||||||
|
.ghost { background: transparent; }
|
||||||
|
.list { display: grid; gap: 10px; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
|
||||||
18
src/client/components/TopBar.vue
Normal file
18
src/client/components/TopBar.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<header class="top glass card">
|
||||||
|
<h1>Printer Central</h1>
|
||||||
|
<p class="subtle">Construí tu formato concatenando comandos de impresión</p>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.top { grid-area: header; }
|
||||||
|
.glass { background: var(--glass); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid var(--border); border-radius: 14px; }
|
||||||
|
.card { padding: 16px; box-shadow: 0 8px 24px rgba(0,0,0,0.24), inset 0 1px 0 rgba(255,255,255,0.05); }
|
||||||
|
h1 { margin: 0 0 4px; font-size: 22px; letter-spacing: 0.2px; }
|
||||||
|
.subtle { color: var(--muted); margin: 0; }
|
||||||
|
</style>
|
||||||
|
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import vue from '@vitejs/plugin-vue';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
strictPort: true,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3030',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: 'public',
|
|
||||||
emptyOutDir: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user