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:
2025-11-24 17:46:20 -06:00
parent f3c13b356b
commit 470ecef4f1
39 changed files with 16114 additions and 1856 deletions

View File

@@ -1,145 +1,41 @@
<template>
<div class="wrap">
<header class="glass card">
<h1>Printer Central</h1>
<p class="subtle">Vista previa y control ePOS (monocromático)</p>
</header>
<main class="grid">
<section class="glass card">
<h2>Texto</h2>
<textarea v-model="form.text" rows="6" placeholder="Escribe el texto a imprimir..." />
<div class="controls">
<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 class="layout">
<TopBar />
<MainPane :adding="adding" @add-ops="onAddOps" />
<SidebarPane
:ops="ops"
@update-op="updateOp"
@remove-op="removeOp"
@move-up="moveUp"
@move-down="moveDown"
@clear-ops="clearOps"
/>
<BottomBar :ops="ops" :result="result" @print="sendOps" />
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
const form = reactive({
text: '',
align: '',
font: '',
w: undefined,
h: undefined,
bold: false,
underline: false,
reverse: false,
smooth: false,
color: '',
feedLines: undefined,
cut: ''
});
import { ref } from 'vue';
import TopBar from './components/TopBar.vue';
import MainPane from './components/MainPane.vue';
import SidebarPane from './components/SidebarPane.vue';
import BottomBar from './components/BottomBar.vue';
const ops = ref([]);
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 resetForm() {
Object.assign(form, { text: '', align: '', font: '', w: undefined, h: undefined, bold: false, underline: false, reverse: false, smooth: false, color: '', feedLines: undefined, cut: '' });
}
function updateOp(index, newOp) { ops.value = ops.value.map((o, i) => (i === index ? newOp : o)); }
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) {
const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
const data = await res.json();
result.value = data;
async function sendOps() {
const res = await fetch('/api/print', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operations: ops.value }) });
result.value = await res.json();
}
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>
<style>
@@ -154,38 +50,29 @@ async function sendOps() { await callApi('/api/print', { operations: ops.value }
* { box-sizing: border-box; }
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; }
.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; }
section.card:nth-child(1) { grid-column: span 12; }
section.card:nth-child(2) { grid-column: span 12; }
section.card:nth-child(3) { grid-column: span 12; }
@media (min-width: 860px) {
section.card:nth-child(1) { grid-column: span 7; }
section.card:nth-child(2) { grid-column: span 5; }
section.card:nth-child(3) { grid-column: span 12; }
.layout {
display: grid;
grid-template-areas:
'header'
'main'
'sidebar'
'footer';
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; }
.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); }
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-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; }
@media (min-width: 900px) {
.layout {
grid-template-areas:
'header header'
'main sidebar'
'footer footer';
grid-template-rows: auto 1fr auto;
grid-template-columns: 3fr 1fr; /* 75/25 */
}
}
</style>

View 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>

View 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>

View 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>

View 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>

View 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>