From 82e4fc91a4438f5e6b468f00b566c99d6f234432 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 12 Feb 2026 08:10:13 +0000 Subject: [PATCH] feat: add web configuration interface --- src/static/app.js | 227 +++++++++++++++++++++++++++++++ src/static/index.html | 88 ++++++++++++ src/static/style.css | 301 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 616 insertions(+) create mode 100644 src/static/app.js create mode 100644 src/static/index.html create mode 100644 src/static/style.css diff --git a/src/static/app.js b/src/static/app.js new file mode 100644 index 0000000..7f89de3 --- /dev/null +++ b/src/static/app.js @@ -0,0 +1,227 @@ +// 全局状态 +let currentTemplateId = null; +let editor = null; +let templates = []; + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + initEditor(); + loadTemplates(); + checkPrinterStatus(); + bindEvents(); + setInterval(checkPrinterStatus, 5000); +}); + +function initEditor() { + editor = CodeMirror.fromTextArea(document.getElementById('yamlEditor'), { + mode: 'yaml', + theme: 'default', + lineNumbers: true, + indentUnit: 2, + tabSize: 2, + lineWrapping: true + }); + editor.on('change', debounce(updatePreview, 500)); +} + +function debounce(fn, delay) { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay); + }; +} + +async function loadTemplates() { + try { + const res = await fetch('/api/templates'); + const data = await res.json(); + if (data.success) { + templates = data.templates; + renderTemplateList(); + } + } catch (err) { + console.error('Failed to load templates:', err); + } +} + +function renderTemplateList() { + const list = document.getElementById('templateList'); + list.innerHTML = ''; + templates.forEach(t => { + const li = document.createElement('li'); + li.textContent = t.name; + li.dataset.id = t.id; + if (t.id === currentTemplateId) li.classList.add('active'); + li.addEventListener('click', () => loadTemplate(t.id)); + list.appendChild(li); + }); +} + +async function loadTemplate(id) { + try { + const res = await fetch(`/api/templates/${id}`); + const data = await res.json(); + if (data.success) { + currentTemplateId = id; + document.getElementById('templateName').value = data.template.name; + const yaml = jsyaml.dump(data.template.config || data.template); + editor.setValue(yaml); + renderTemplateList(); + updatePreview(); + } + } catch (err) { + console.error('Failed to load template:', err); + } +} + +function updatePreview() { + try { + const yaml = editor.getValue(); + if (!yaml.trim()) { + document.getElementById('receiptPreview').innerHTML = ''; + return; + } + const config = jsyaml.load(yaml); + const testData = getTestData(); + const html = renderToHTML(config, testData); + document.getElementById('receiptPreview').innerHTML = html; + } catch (err) { + console.log('YAML parse error:', err); + } +} + +function renderToHTML(config, data) { + if (!config.blocks || !Array.isArray(config.blocks)) { + return '

配置无效

'; + } + let html = ''; + for (const block of config.blocks) { + html += renderBlockToHTML(block, data); + } + return html; +} + +function renderBlockToHTML(block, data) { + const style = []; + if (block.bold) style.push('text-bold'); + if (block.fontSize === 'small') style.push('text-small'); + if (block.fontSize === 'large') style.push('text-large'); + if (block.fontSize === 'xlarge') style.push('text-xlarge'); + const align = block.align || 'left'; + style.push(`text-${align}`); + + switch (block.type) { + case 'text': { + const content = Mustache.render(block.content, data); + return `
${escapeHtml(content)}
`; + } + case 'divider': { + const char = block.char || '-'; + return `
${char.repeat(48)}
`; + } + case 'row': { + const cols = block.columns.map(col => { + const content = col.content ? Mustache.render(col.content, data) : ''; + return `${escapeHtml(content)}`; + }); + return `
${cols.join('')}
`; + } + case 'space': { + return '
'.repeat(block.lines || 1); + } + default: + return ''; + } +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function getTestData() { + try { + const json = document.getElementById('dataEditor').value; + return json ? JSON.parse(json) : {}; + } catch { + return {}; + } +} + +async function checkPrinterStatus() { + try { + const res = await fetch('/api/printer/status'); + const data = await res.json(); + const statusEl = document.getElementById('printerStatus'); + const dot = statusEl.querySelector('.status-dot'); + const text = statusEl.querySelector('.status-text'); + if (data.success && data.printer.online) { + dot.className = 'status-dot online'; + text.textContent = '在线'; + } else { + dot.className = 'status-dot offline'; + text.textContent = '离线'; + } + } catch { + const dot = document.querySelector('.status-dot'); + const text = document.querySelector('.status-text'); + if (dot) dot.className = 'status-dot offline'; + if (text) text.textContent = '离线'; + } +} + +function bindEvents() { + document.getElementById('newTemplate').addEventListener('click', () => { + currentTemplateId = null; + document.getElementById('templateName').value = ''; + editor.setValue(`name: "新模板" +id: "new-template" +width: 80mm +blocks: + - type: text + content: "Hello World" + align: center +`); + document.getElementById('receiptPreview').innerHTML = ''; + renderTemplateList(); + }); + + document.getElementById('saveTemplate').addEventListener('click', async () => { + if (!currentTemplateId) { + const id = prompt('输入模板 ID(英文、数字、连字符):'); + if (!id) return; + currentTemplateId = id; + } + try { + const yaml = editor.getValue(); + const config = jsyaml.load(yaml); + const name = document.getElementById('templateName').value || config.name || '未命名'; + const res = await fetch('/api/templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: currentTemplateId, name, config }) + }); + const data = await res.json(); + if (data.success) { + alert('保存成功'); + loadTemplates(); + } else { + alert('保存失败: ' + data.error.message); + } + } catch (err) { + alert('保存失败: ' + err.message); + } + }); + + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const tab = e.target.dataset.tab; + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + e.target.classList.add('active'); + document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden')); + document.getElementById(tab + 'Tab').classList.remove('hidden'); + }); + }); +} diff --git a/src/static/index.html b/src/static/index.html new file mode 100644 index 0000000..2a88a39 --- /dev/null +++ b/src/static/index.html @@ -0,0 +1,88 @@ + + + + + + Receipt Printer - 小票打印机配置 + + + + +
+
+

🖨️ Receipt Printer

+
+ + 离线 +
+
+ +
+ + + + +
+
+ +
+ + +
+
+
+ +
+
+ 插入: + + + + + + + +
+
+ + + +
+
+ + + + + + + + diff --git a/src/static/style.css b/src/static/style.css new file mode 100644 index 0000000..1cbc72b --- /dev/null +++ b/src/static/style.css @@ -0,0 +1,301 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; + color: #333; + height: 100vh; + overflow: hidden; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header */ +.header { + background: #fff; + padding: 12px 24px; + border-bottom: 1px solid #e0e0e0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header h1 { + font-size: 20px; + font-weight: 600; +} + +.printer-status { + display: flex; + align-items: center; + gap: 8px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-dot.online { background: #4caf50; } +.status-dot.offline { background: #f44336; } + +/* Main Layout */ +.main { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Sidebar */ +.sidebar { + width: 220px; + background: #fff; + border-right: 1px solid #e0e0e0; + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: 16px; + border-bottom: 1px solid #e0e0e0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.sidebar-header h2 { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + color: #666; +} + +.template-list { + list-style: none; + overflow-y: auto; + flex: 1; +} + +.template-list li { + padding: 12px 16px; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background 0.2s; +} + +.template-list li:hover { + background: #f5f5f5; +} + +.template-list li.active { + background: #e3f2fd; + color: #1976d2; +} + +/* Editor */ +.editor { + flex: 1; + display: flex; + flex-direction: column; + background: #fff; +} + +.editor-header { + padding: 12px 16px; + border-bottom: 1px solid #e0e0e0; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.template-name { + flex: 1; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.editor-actions { + display: flex; + gap: 8px; +} + +.editor-body { + flex: 1; + position: relative; +} + +.CodeMirror { + height: 100%; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 14px; +} + +.editor-toolbar { + padding: 12px 16px; + border-top: 1px solid #e0e0e0; + display: flex; + align-items: center; + gap: 8px; +} + +.editor-toolbar span { + color: #666; + font-size: 12px; +} + +/* Preview Panel */ +.preview-panel { + width: 360px; + background: #fff; + border-left: 1px solid #e0e0e0; + display: flex; + flex-direction: column; +} + +.preview-tabs { + display: flex; + border-bottom: 1px solid #e0e0e0; +} + +.tab-btn { + flex: 1; + padding: 12px; + border: none; + background: none; + cursor: pointer; + font-size: 13px; + color: #666; + transition: all 0.2s; +} + +.tab-btn:hover { + background: #f5f5f5; +} + +.tab-btn.active { + color: #1976d2; + border-bottom: 2px solid #1976d2; +} + +.tab-content { + flex: 1; + overflow: auto; + padding: 16px; +} + +.tab-content.hidden { + display: none; +} + +/* Receipt Preview */ +.receipt-preview { + background: #fff; + border: 1px solid #ddd; + padding: 20px; + font-family: monospace; + font-size: 12px; + line-height: 1.6; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.receipt-preview .text-left { text-align: left; } +.receipt-preview .text-center { text-align: center; } +.receipt-preview .text-right { text-align: right; } +.receipt-preview .text-bold { font-weight: bold; } +.receipt-preview .text-small { font-size: 10px; } +.receipt-preview .text-large { font-size: 14px; } +.receipt-preview .text-xlarge { font-size: 18px; } +.receipt-preview .divider { + border-top: 1px dashed #999; + margin: 8px 0; +} + +/* Data Editor */ +#dataEditor { + width: 100%; + height: 300px; + padding: 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: monospace; + font-size: 13px; + resize: vertical; +} + +#schemaDisplay { + background: #f5f5f5; + padding: 16px; + border-radius: 4px; + font-size: 12px; + overflow: auto; +} + +/* Buttons */ +.btn { + padding: 8px 16px; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.btn:hover { + background: #f5f5f5; +} + +.btn-primary { + background: #1976d2; + color: #fff; + border-color: #1976d2; +} + +.btn-primary:hover { + background: #1565c0; +} + +.btn-danger { + color: #f44336; + border-color: #f44336; +} + +.btn-danger:hover { + background: #ffebee; +} + +.btn-sm { + padding: 4px 12px; + font-size: 12px; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #a1a1a1; +}