From a9bc4edc3fd80458acb93504c12cb0a194bbfbbd Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 16 Feb 2026 18:33:08 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=92=8C=E6=B5=8B=E8=AF=95=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 Mustache.js 加载问题(内联到 HTML) - 添加所有 block 类型的渲染支持(list, table, barcode, image) - 为每个模板添加测试数据(daily-todo, food-order, fancy-receipt, ticket-list, long-text) - 选择模板时自动加载对应测试数据 - 添加 Schema 页面显示功能 - 添加服务器启动和监控脚本 - 更新样式支持斜体、下划线等文本样式 --- TODO.md | 24 +-- monitor.sh | 31 ++++ src/static/app.js | 252 ++++++++++++++++++++++++++++- src/static/index.html | 319 ++++++++++++++++++++++++++++++++++++- src/static/mustache.min.js | 1 + src/static/style.css | 11 ++ start-server.sh | 28 ++++ 7 files changed, 644 insertions(+), 22 deletions(-) create mode 100755 monitor.sh create mode 100644 src/static/mustache.min.js create mode 100755 start-server.sh diff --git a/TODO.md b/TODO.md index a366ef7..b238a15 100644 --- a/TODO.md +++ b/TODO.md @@ -2,19 +2,19 @@ ## 任务列表 -- [ ] Task 1: 项目初始化 -- [ ] Task 2: 模板类型定义 -- [ ] Task 3: YAML 模板解析器 -- [ ] Task 4: Schema 提取器 -- [ ] Task 5: ESC/POS 生成器 -- [ ] Task 6: 打印机连接器 -- [ ] Task 7: Hono HTTP 服务搭建 -- [ ] Task 8: Web 界面基础 -- [ ] Task 9: 加载示例模板 -- [ ] Task 10: 启动脚本和 README +- [x] Task 1: 项目初始化 +- [x] Task 2: 模板类型定义 +- [x] Task 3: YAML 模板解析器 +- [x] Task 4: Schema 提取器 +- [x] Task 5: ESC/POS 生成器 +- [x] Task 6: 打印机连接器 +- [x] Task 7: Hono HTTP 服务 +- [x] Task 8: Web 配置界面 +- [x] Task 9: 加载示例模板 +- [x] Task 10: 启动脚本和 README ## 当前任务 -Task 1: 项目初始化 +所有任务已完成 ✅ ## 已完成 -(暂无) +10/10 任务全部完成并推送到 Gitea diff --git a/monitor.sh b/monitor.sh new file mode 100755 index 0000000..0873615 --- /dev/null +++ b/monitor.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# 监控 Receipt Printer 服务器,如果停止则自动重启 + +PIDFILE="/tmp/receipt-printer.pid" +LOGFILE="/home/ching/.openclaw/workspace/receipt-printer/server.log" + +check_and_restart() { + # 检查健康端点 + if ! curl -s http://localhost:3000/health >/dev/null 2>&1; then + echo "[$(date)] Server not responding, restarting..." + + # 尝试杀掉旧进程 + if [ -f "$PIDFILE" ]; then + OLD_PID=$(cat "$PIDFILE" 2>/dev/null) + kill "$OLD_PID" 2>/dev/null + sleep 1 + fi + + # 重启 + cd /home/ching/.openclaw/workspace/receipt-printer + bun run src/server.ts >> "$LOGFILE" 2>&1 & +echo $! > "$PIDFILE" + echo "[$(date)] Server restarted with PID: $!" + fi +} + +# 每分钟检查一次 +while true; do + check_and_restart + sleep 60 +done diff --git a/src/static/app.js b/src/static/app.js index 7f89de3..da7eff2 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -1,8 +1,107 @@ // 全局状态 let currentTemplateId = null; let editor = null; +let dataEditor = null; let templates = []; +// 各模板的测试数据 +const templateTestData = { + 'daily-todo': { + date: '2025-02-16', + tasks: [ + { status: '☐', title: '修复预览显示问题' }, + { status: '☑', title: '添加测试数据支持' }, + { status: '☐', title: '测试打印功能' }, + { status: '☑', title: '初始化项目结构' } + ], + completedCount: 2 + }, + 'food-order-simple': { + orderType: '外带', + orderId: 'ORD-20250216-001', + items: [ + { quantity: '1x', name: '麦辣鸡腿堡套餐', notes: ['生菜', '番茄'] }, + { quantity: '2x', name: '中薯条', notes: [] }, + { quantity: '1x', name: '可乐(大)', notes: ['少冰'] } + ], + pickupNumber: '88', + timestamp: '2025-02-16 12:34' + }, + 'fancy-receipt': { + logoUrl: 'https://via.placeholder.com/150x50?text=LOGO', + taglineEn: 'PREMIUM DINING EXPERIENCE', + shopName: '铁板烧 · 樱花', + menuTitle: '点菜单', + datetime: '2025-02-16 19:30', + items: [ + ['和牛牛排', '1', '¥288'], + ['海鲜拼盘', '1', '¥168'], + ['季节蔬菜', '1', '¥48'], + ['清酒(壶)', '2', '¥96'] + ], + totalQuantity: 5, + subtotal: '600', + taxRate: '10%', + tax: '60', + total: '660', + discount: '-¥50', + signature: '谢谢惠顾', + copyright: '© 2025 樱花铁板烧' + }, + 'ticket-list': { + project: 'OpenClaw', + filter: '进行中', + tickets: [ + { + id: '123', + status: '进行中', + priority: '高', + title: '修复预览渲染问题', + labels: 'bug, frontend', + assignee: '张三', + dueDate: '2025-02-17' + }, + { + id: '124', + status: '待评审', + priority: '中', + title: '添加打印队列功能', + labels: 'feature', + assignee: '李四', + dueDate: '2025-02-20' + }, + { + id: '125', + status: '已完成', + priority: '低', + title: '优化模板加载速度', + labels: 'perf', + assignee: '王五', + dueDate: '2025-02-15' + } + ], + statusStats: [ + { status: '进行中', count: 1 }, + { status: '待评审', count: 1 }, + { status: '已完成', count: 1 } + ] + }, + 'long-text': { + title: '如何构建高效的打印系统', + author: '技术团队', + publishDate: '2025-02-10', + paragraphs: [ + { content: '在现代商业环境中,小票打印系统是许多业务的核心组件。无论是餐饮、零售还是物流,一个可靠的打印系统都能显著提升运营效率。' }, + { content: '本文将探讨如何构建一个基于 WiFi ESC/POS 协议的高效打印系统,包括模板配置、数据绑定和实时预览等关键功能。' }, + { content: '首先,我们需要理解 ESC/POS 指令集。这是爱普生公司开发的一套标准打印指令,被广泛应用于热敏打印机。通过这些指令,我们可以控制文本样式、条码打印、甚至图片输出。' }, + { content: '其次,模板系统的设计至关重要。我们采用 YAML 格式定义模板,因为它支持注释且易于阅读。每个模板由多个 block 组成,包括文本、表格、列表、条码等元素。' }, + { content: '最后,实时预览功能让用户可以在打印前看到效果。我们将 ESC/POS 指令转换为 HTML 进行浏览器渲染,既保留了打印效果,又提供了良好的交互体验。' } + ], + wordCount: 356, + printDate: '2025-02-16 17:15' + } +}; + // 初始化 document.addEventListener('DOMContentLoaded', () => { initEditor(); @@ -13,6 +112,7 @@ document.addEventListener('DOMContentLoaded', () => { }); function initEditor() { + // 初始化 YAML 编辑器 editor = CodeMirror.fromTextArea(document.getElementById('yamlEditor'), { mode: 'yaml', theme: 'default', @@ -22,6 +122,18 @@ function initEditor() { lineWrapping: true }); editor.on('change', debounce(updatePreview, 500)); + + // 初始化数据编辑器 + dataEditor = CodeMirror.fromTextArea(document.getElementById('dataEditor'), { + mode: 'javascript', + theme: 'default', + lineNumbers: true, + indentUnit: 2, + tabSize: 2, + lineWrapping: true, + json: true + }); + dataEditor.on('change', debounce(updatePreview, 300)); } function debounce(fn, delay) { @@ -67,8 +179,23 @@ async function loadTemplate(id) { document.getElementById('templateName').value = data.template.name; const yaml = jsyaml.dump(data.template.config || data.template); editor.setValue(yaml); + + // 加载对应的测试数据 + const testData = templateTestData[id] || {}; + dataEditor.setValue(JSON.stringify(testData, null, 2)); + renderTemplateList(); - updatePreview(); + + // 切换到预览标签 + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + document.querySelector('[data-tab="preview"]').classList.add('active'); + document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden')); + document.getElementById('previewTab').classList.remove('hidden'); + + // 延迟一点再更新预览,确保编辑器已经更新 + setTimeout(() => { + updatePreview(); + }, 100); } } catch (err) { console.error('Failed to load template:', err); @@ -87,7 +214,8 @@ function updatePreview() { const html = renderToHTML(config, testData); document.getElementById('receiptPreview').innerHTML = html; } catch (err) { - console.log('YAML parse error:', err); + console.error('Preview error:', err.message); + document.getElementById('receiptPreview').innerHTML = '

预览错误: ' + err.message + '

'; } } @@ -105,14 +233,17 @@ function renderToHTML(config, data) { function renderBlockToHTML(block, data) { const style = []; if (block.bold) style.push('text-bold'); + if (block.italic) style.push('text-italic'); + if (block.underline) style.push('text-underline'); 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': { + if (!block.content) return ''; const content = Mustache.render(block.content, data); return `
${escapeHtml(content)}
`; } @@ -123,18 +254,100 @@ function renderBlockToHTML(block, data) { case 'row': { const cols = block.columns.map(col => { const content = col.content ? Mustache.render(col.content, data) : ''; - return `${escapeHtml(content)}`; + const colStyle = []; + if (col.bold) colStyle.push('text-bold'); + // 处理 width:百分比转换为 flex 比例 + if (col.width) { + const widthVal = col.width.toString().replace('%', ''); + colStyle.push(`flex: ${widthVal}`); + } else { + colStyle.push('flex: 1'); + } + const align = col.align || 'left'; + return `${escapeHtml(content)}`; }); - return `
${cols.join('')}
`; + return `
${cols.join('')}
`; } case 'space': { return '
'.repeat(block.lines || 1); } + case 'list': { + const listDataKey = block.data.replace(/[{}]/g, ''); + const listData = getNestedValue(data, listDataKey); + if (!Array.isArray(listData) || listData.length === 0) { + return ''; + } + let listHtml = ''; + for (const item of listData) { + for (const itemBlock of block.itemTemplate) { + listHtml += renderBlockToHTML(itemBlock, { ...data, ...item, '.': item }); + } + } + return listHtml; + } + case 'table': { + const tableDataKey = block.data.replace(/[{}]/g, ''); + const tableData = getNestedValue(data, tableDataKey) || []; + if (!Array.isArray(tableData) || tableData.length === 0) { + return '
[空表格]
'; + } + let tableHtml = '
'; + for (const row of tableData) { + let rowHtml = '
'; + const cols = block.columns || []; + for (let i = 0; i < cols.length; i++) { + const col = cols[i]; + // 支持数组格式 [col1, col2, col3] 或对象格式 + let cellValue; + if (Array.isArray(row)) { + cellValue = row[i] !== undefined ? row[i] : ''; + } else { + // 尝试从对象获取,或者使用 colN 命名 + cellValue = row[`col${i}`] !== undefined ? row[`col${i}`] : (row[Object.keys(row)[i]] || ''); + } + const align = col.align || 'left'; + const width = col.width ? parseFloat(col.width.toString().replace('%', '')) : (100 / cols.length); + rowHtml += `${escapeHtml(String(cellValue))}`; + } + rowHtml += '
'; + tableHtml += rowHtml; + } + tableHtml += '
'; + return tableHtml; + } + case 'barcode': { + const barcodeData = Mustache.render(block.data, data); + const height = block.height || 64; + return `
+ + + [条码: ${escapeHtml(barcodeData)}] + +
`; + } + case 'image': { + const imgSrc = Mustache.render(block.src || '', data); + const maxWidth = block.maxWidth || 150; + return `
+ +
[图片: ${escapeHtml(imgSrc)}]
+
`; + } default: return ''; } } +function getNestedValue(obj, path) { + // 处理数组长度访问,如 tasks.length + if (path.endsWith('.length')) { + const arrayPath = path.slice(0, -7); + const arr = arrayPath.split('.').reduce((acc, part) => acc && acc[part], obj); + return Array.isArray(arr) ? arr.length : 0; + } + return path.split('.').reduce((acc, part) => acc && acc[part], obj); +} + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; @@ -143,8 +356,10 @@ function escapeHtml(text) { function getTestData() { try { - const json = document.getElementById('dataEditor').value; - return json ? JSON.parse(json) : {}; + if (!dataEditor) return {}; + const json = dataEditor.getValue(); + if (!json || !json.trim()) return {}; + return JSON.parse(json); } catch { return {}; } @@ -184,6 +399,7 @@ blocks: content: "Hello World" align: center `); + dataEditor.setValue('{}'); document.getElementById('receiptPreview').innerHTML = ''; renderTemplateList(); }); @@ -216,12 +432,32 @@ blocks: }); document.querySelectorAll('.tab-btn').forEach(btn => { - btn.addEventListener('click', (e) => { + btn.addEventListener('click', async (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'); + + // 如果切换到 schema 标签,加载 schema 数据 + if (tab === 'schema' && currentTemplateId) { + await loadSchema(currentTemplateId); + } }); }); } + +async function loadSchema(templateId) { + try { + const res = await fetch(`/api/templates/${templateId}/schema`); + const data = await res.json(); + const schemaDisplay = document.getElementById('schemaDisplay'); + if (data.success) { + schemaDisplay.textContent = JSON.stringify(data.schema, null, 2); + } else { + schemaDisplay.textContent = '无法加载 Schema: ' + (data.error?.message || '未知错误'); + } + } catch (err) { + document.getElementById('schemaDisplay').textContent = '加载失败: ' + err.message; + } +} diff --git a/src/static/index.html b/src/static/index.html index 2a88a39..563d704 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -81,8 +81,323 @@ + - - + + + + diff --git a/src/static/mustache.min.js b/src/static/mustache.min.js new file mode 100644 index 0000000..7916295 --- /dev/null +++ b/src/static/mustache.min.js @@ -0,0 +1 @@ +var objectToString=Object.prototype.toString,isArray=Array.isArray||function(e){return"[object Array]"===objectToString.call(e)};function isFunction(e){return"function"==typeof e}function typeStr(e){return isArray(e)?"array":typeof e}function escapeRegExp(e){return e.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g,"\\$&")}function hasProperty(e,t){return null!=e&&"object"==typeof e&&t in e}function primitiveHasOwnProperty(e,t){return null!=e&&"object"!=typeof e&&e.hasOwnProperty&&e.hasOwnProperty(t)}var regExpTest=RegExp.prototype.test;function testRegExp(e,t){return regExpTest.call(e,t)}var nonSpaceRe=/\S/;function isWhitespace(e){return!testRegExp(nonSpaceRe,e)}var entityMap={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/","`":"`","=":"="};function escapeHtml(e){return String(e).replace(/[&<>"'`=\/]/g,function(e){return entityMap[e]})}var whiteRe=/\s*/,spaceRe=/\s+/,equalsRe=/\s*=/,curlyRe=/\s*\}/,tagRe=/#|\^|\/|>|\{|&|=|!/;function parseTemplate(e,t){if(!e)return[];var r,n,i,a=!1,s=[],o=[],c=[],p=!1,u=!1,h="",l=0;function f(){if(p&&!u)for(;c.length;)delete o[c.pop()];else c=[];p=!1,u=!1}function g(e){if("string"==typeof e&&(e=e.split(spaceRe,2)),!isArray(e)||2!==e.length)throw new Error("Invalid tags: "+e);r=new RegExp(escapeRegExp(e[0])+"\\s*"),n=new RegExp("\\s*"+escapeRegExp(e[1])),i=new RegExp("\\s*"+escapeRegExp("}"+e[1]))}g(t||mustache.tags);for(var d,v,y,m,w,x,C=new Scanner(e);!C.eos();){if(d=C.pos,y=C.scanUntil(r))for(var W=0,R=y.length;W0?i[i.length-1][4]:r;break;default:n.push(t)}return r}function Scanner(e){this.string=e,this.tail=e,this.pos=0}function Context(e,t){this.view=e,this.cache={".":this.view},this.parent=t}function Writer(){this.templateCache={_cache:{},set:function(e,t){this._cache[e]=t},get:function(e){return this._cache[e]},clear:function(){this._cache={}}}}Scanner.prototype.eos=function(){return""===this.tail},Scanner.prototype.scan=function(e){var t=this.tail.match(e);if(!t||0!==t.index)return"";var r=t[0];return this.tail=this.tail.substring(r.length),this.pos+=r.length,r},Scanner.prototype.scanUntil=function(e){var t,r=this.tail.search(e);switch(r){case-1:t=this.tail,this.tail="";break;case 0:t="";break;default:t=this.tail.substring(0,r),this.tail=this.tail.substring(r)}return this.pos+=t.length,t},Context.prototype.push=function(e){return new Context(e,this)},Context.prototype.lookup=function(e){var t,r=this.cache;if(r.hasOwnProperty(e))t=r[e];else{for(var n,i,a,s=this,o=!1;s;){if(e.indexOf(".")>0)for(n=s.view,i=e.split("."),a=0;null!=n&&a"===s?o=this.renderPartial(a,t,r,i):"&"===s?o=this.unescapedValue(a,t):"name"===s?o=this.escapedValue(a,t,i):"text"===s&&(o=this.rawValue(a)),void 0!==o&&(c+=o);return c},Writer.prototype.renderSection=function(e,t,r,n,i){var a=this,s="",o=t.lookup(e[1]);if(o){if(isArray(o))for(var c=0,p=o.length;c0||!r)&&(i[a]=n+i[a]);return i.join("\n")},Writer.prototype.renderPartial=function(e,t,r,n){if(r){var i=this.getConfigTags(n),a=isFunction(r)?r(e[1]):r[e[1]];if(null!=a){var s=e[6],o=e[5],c=e[4],p=a;0==o&&c&&(p=this.indentPartial(a,c,s));var u=this.parse(p,i);return this.renderTokens(u,t,r,p,n)}}},Writer.prototype.unescapedValue=function(e,t){var r=t.lookup(e[1]);if(null!=r)return r},Writer.prototype.escapedValue=function(e,t,r){var n=this.getConfigEscape(r)||mustache.escape,i=t.lookup(e[1]);if(null!=i)return"number"==typeof i&&n===mustache.escape?String(i):n(i)},Writer.prototype.rawValue=function(e){return e[1]},Writer.prototype.getConfigTags=function(e){return isArray(e)?e:e&&"object"==typeof e?e.tags:void 0},Writer.prototype.getConfigEscape=function(e){return e&&"object"==typeof e&&!isArray(e)?e.escape:void 0};var mustache={name:"mustache.js",version:"4.2.0",tags:["{{","}}"],clearCache:void 0,escape:void 0,parse:void 0,render:void 0,Scanner:void 0,Context:void 0,Writer:void 0,set templateCache(e){defaultWriter.templateCache=e},get templateCache(){return defaultWriter.templateCache}},defaultWriter=new Writer;mustache.clearCache=function(){return defaultWriter.clearCache()},mustache.parse=function(e,t){return defaultWriter.parse(e,t)},mustache.render=function(e,t,r,n){if("string"!=typeof e)throw new TypeError('Invalid template! Template should be a "string" but "'+typeStr(e)+'" was given as the first argument for mustache#render(template, view, partials)');return defaultWriter.render(e,t,r,n)},mustache.escape=escapeHtml,mustache.Scanner=Scanner,mustache.Context=Context,mustache.Writer=Writer;export default mustache; \ No newline at end of file diff --git a/src/static/style.css b/src/static/style.css index 1cbc72b..b27cc53 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -214,6 +214,8 @@ body { .receipt-preview .text-center { text-align: center; } .receipt-preview .text-right { text-align: right; } .receipt-preview .text-bold { font-weight: bold; } +.receipt-preview .text-italic { font-style: italic; } +.receipt-preview .text-underline { text-decoration: underline; } .receipt-preview .text-small { font-size: 10px; } .receipt-preview .text-large { font-size: 14px; } .receipt-preview .text-xlarge { font-size: 18px; } @@ -234,6 +236,15 @@ body { resize: vertical; } +/* CodeMirror for data editor */ +#dataTab .CodeMirror { + height: 400px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; +} + #schemaDisplay { background: #f5f5f5; padding: 16px; diff --git a/start-server.sh b/start-server.sh new file mode 100755 index 0000000..6052a4f --- /dev/null +++ b/start-server.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# 启动 Receipt Printer 服务器并监控 + +PIDFILE="/tmp/receipt-printer.pid" +LOGFILE="/home/ching/.openclaw/workspace/receipt-printer/server.log" + +cd /home/ching/.openclaw/workspace/receipt-printer + +# 如果已有进程在运行,先停止 +if [ -f "$PIDFILE" ]; then + OLD_PID=$(cat "$PIDFILE" 2>/dev/null) + if kill -0 "$OLD_PID" 2>/dev/null; then + echo "Stopping existing server (PID: $OLD_PID)..." + kill "$OLD_PID" + sleep 2 + fi +fi + +# 清理旧日志 +> "$LOGFILE" + +# 启动服务器 +bun run src/server.ts >> "$LOGFILE" 2>&1 & +echo $! > "$PIDFILE" + +echo "Receipt Printer Server started with PID: $!" +echo "Log file: $LOGFILE" +echo "Test: curl http://localhost:3000/health"