feat: 完善预览功能和测试数据支持

- 修复 Mustache.js 加载问题(内联到 HTML)
- 添加所有 block 类型的渲染支持(list, table, barcode, image)
- 为每个模板添加测试数据(daily-todo, food-order, fancy-receipt, ticket-list, long-text)
- 选择模板时自动加载对应测试数据
- 添加 Schema 页面显示功能
- 添加服务器启动和监控脚本
- 更新样式支持斜体、下划线等文本样式
This commit is contained in:
Developer 2026-02-16 18:33:08 +00:00
parent 2c18fca719
commit a9bc4edc3f
7 changed files with 644 additions and 22 deletions

24
TODO.md
View File

@ -2,19 +2,19 @@
## 任务列表 ## 任务列表
- [ ] Task 1: 项目初始化 - [x] Task 1: 项目初始化
- [ ] Task 2: 模板类型定义 - [x] Task 2: 模板类型定义
- [ ] Task 3: YAML 模板解析器 - [x] Task 3: YAML 模板解析器
- [ ] Task 4: Schema 提取器 - [x] Task 4: Schema 提取器
- [ ] Task 5: ESC/POS 生成器 - [x] Task 5: ESC/POS 生成器
- [ ] Task 6: 打印机连接器 - [x] Task 6: 打印机连接器
- [ ] Task 7: Hono HTTP 服务搭建 - [x] Task 7: Hono HTTP 服务
- [ ] Task 8: Web 界面基础 - [x] Task 8: Web 配置界面
- [ ] Task 9: 加载示例模板 - [x] Task 9: 加载示例模板
- [ ] Task 10: 启动脚本和 README - [x] Task 10: 启动脚本和 README
## 当前任务 ## 当前任务
Task 1: 项目初始化 所有任务已完成 ✅
## 已完成 ## 已完成
(暂无) 10/10 任务全部完成并推送到 Gitea

31
monitor.sh Executable file
View File

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

View File

@ -1,8 +1,107 @@
// 全局状态 // 全局状态
let currentTemplateId = null; let currentTemplateId = null;
let editor = null; let editor = null;
let dataEditor = null;
let templates = []; 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', () => { document.addEventListener('DOMContentLoaded', () => {
initEditor(); initEditor();
@ -13,6 +112,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
function initEditor() { function initEditor() {
// 初始化 YAML 编辑器
editor = CodeMirror.fromTextArea(document.getElementById('yamlEditor'), { editor = CodeMirror.fromTextArea(document.getElementById('yamlEditor'), {
mode: 'yaml', mode: 'yaml',
theme: 'default', theme: 'default',
@ -22,6 +122,18 @@ function initEditor() {
lineWrapping: true lineWrapping: true
}); });
editor.on('change', debounce(updatePreview, 500)); 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) { function debounce(fn, delay) {
@ -67,8 +179,23 @@ async function loadTemplate(id) {
document.getElementById('templateName').value = data.template.name; document.getElementById('templateName').value = data.template.name;
const yaml = jsyaml.dump(data.template.config || data.template); const yaml = jsyaml.dump(data.template.config || data.template);
editor.setValue(yaml); editor.setValue(yaml);
// 加载对应的测试数据
const testData = templateTestData[id] || {};
dataEditor.setValue(JSON.stringify(testData, null, 2));
renderTemplateList(); 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) { } catch (err) {
console.error('Failed to load template:', err); console.error('Failed to load template:', err);
@ -87,7 +214,8 @@ function updatePreview() {
const html = renderToHTML(config, testData); const html = renderToHTML(config, testData);
document.getElementById('receiptPreview').innerHTML = html; document.getElementById('receiptPreview').innerHTML = html;
} catch (err) { } catch (err) {
console.log('YAML parse error:', err); console.error('Preview error:', err.message);
document.getElementById('receiptPreview').innerHTML = '<p style="color: #f44336; padding: 10px;">预览错误: ' + err.message + '</p>';
} }
} }
@ -105,14 +233,17 @@ function renderToHTML(config, data) {
function renderBlockToHTML(block, data) { function renderBlockToHTML(block, data) {
const style = []; const style = [];
if (block.bold) style.push('text-bold'); 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 === 'small') style.push('text-small');
if (block.fontSize === 'large') style.push('text-large'); if (block.fontSize === 'large') style.push('text-large');
if (block.fontSize === 'xlarge') style.push('text-xlarge'); if (block.fontSize === 'xlarge') style.push('text-xlarge');
const align = block.align || 'left'; const align = block.align || 'left';
style.push(`text-${align}`); style.push(`text-${align}`);
switch (block.type) { switch (block.type) {
case 'text': { case 'text': {
if (!block.content) return '';
const content = Mustache.render(block.content, data); const content = Mustache.render(block.content, data);
return `<div class="${style.join(' ')}">${escapeHtml(content)}</div>`; return `<div class="${style.join(' ')}">${escapeHtml(content)}</div>`;
} }
@ -123,18 +254,100 @@ function renderBlockToHTML(block, data) {
case 'row': { case 'row': {
const cols = block.columns.map(col => { const cols = block.columns.map(col => {
const content = col.content ? Mustache.render(col.content, data) : ''; const content = col.content ? Mustache.render(col.content, data) : '';
return `<span>${escapeHtml(content)}</span>`; 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 `<span class="${colStyle.join(' ')}" style="text-align: ${align}">${escapeHtml(content)}</span>`;
}); });
return `<div style="display: flex; justify-content: space-between;">${cols.join('')}</div>`; return `<div style="display: flex; justify-content: space-between; gap: 8px;">${cols.join('')}</div>`;
} }
case 'space': { case 'space': {
return '<br>'.repeat(block.lines || 1); return '<br>'.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 '<div style="color: #999; padding: 8px;">[空表格]</div>';
}
let tableHtml = '<div class="table" style="width: 100%;">';
for (const row of tableData) {
let rowHtml = '<div style="display: flex; justify-content: space-between; gap: 4px;">';
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 += `<span style="text-align: ${align}; flex: ${width}; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(String(cellValue))}</span>`;
}
rowHtml += '</div>';
tableHtml += rowHtml;
}
tableHtml += '</div>';
return tableHtml;
}
case 'barcode': {
const barcodeData = Mustache.render(block.data, data);
const height = block.height || 64;
return `<div class="text-${block.align || 'center'}" style="margin: 8px 0;">
<svg style="max-width: 100%;" height="${height}">
<rect x="0" y="0" width="100%" height="100%" fill="white"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="monospace" font-size="14">[条码: ${escapeHtml(barcodeData)}]</text>
</svg>
</div>`;
}
case 'image': {
const imgSrc = Mustache.render(block.src || '', data);
const maxWidth = block.maxWidth || 150;
return `<div class="text-${block.align || 'center'}" style="margin: 8px 0;">
<img src="${escapeHtml(imgSrc)}" style="max-width: ${maxWidth}px;" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<div style="display: none; color: #999; font-size: 12px;">[图片: ${escapeHtml(imgSrc)}]</div>
</div>`;
}
default: default:
return ''; 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) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
@ -143,8 +356,10 @@ function escapeHtml(text) {
function getTestData() { function getTestData() {
try { try {
const json = document.getElementById('dataEditor').value; if (!dataEditor) return {};
return json ? JSON.parse(json) : {}; const json = dataEditor.getValue();
if (!json || !json.trim()) return {};
return JSON.parse(json);
} catch { } catch {
return {}; return {};
} }
@ -184,6 +399,7 @@ blocks:
content: "Hello World" content: "Hello World"
align: center align: center
`); `);
dataEditor.setValue('{}');
document.getElementById('receiptPreview').innerHTML = ''; document.getElementById('receiptPreview').innerHTML = '';
renderTemplateList(); renderTemplateList();
}); });
@ -216,12 +432,32 @@ blocks:
}); });
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', async (e) => {
const tab = e.target.dataset.tab; const tab = e.target.dataset.tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active'); e.target.classList.add('active');
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden')); document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
document.getElementById(tab + 'Tab').classList.remove('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;
}
}

View File

@ -81,8 +81,323 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/4.2.0/mustache.min.js"></script>
<script src="app.js"></script> <!-- Mustache.js 内联加载(避免 CDN 失败) -->
<script>
// Mustache.js 4.2.0
(function(global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Mustache = factory());
}(this, function() {
'use strict';
// 辅助函数
var objectToString = Object.prototype.toString;
var isArray = Array.isArray || function(obj) {
return objectToString.call(obj) === '[object Array]';
};
function isFunction(obj) {
return typeof obj === 'function';
}
function typeStr(obj) {
return isArray(obj) ? 'array' : typeof obj;
}
function escapeRegExp(string) {
return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&');
}
function hasProperty(obj, propName) {
return obj != null && typeof obj === 'object' && (propName in obj);
}
function primitiveHasOwnProperty(obj, propName) {
return obj != null && typeof obj !== 'object' && obj.hasOwnProperty && obj.hasOwnProperty(propName);
}
var regExpTest = RegExp.prototype.test;
function testRegExp(re, string) {
return regExpTest.call(re, string);
}
var nonSpaceRe = /\S/;
function isWhitespace(string) {
return !testRegExp(nonSpaceRe, string);
}
var entityMap = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;','/':'&#x2F;','`':'&#x60;','=':'&#x3D;'};
function escapeHtml(string) {
return String(string).replace(/[&<>"'`=\/]/g, function(s) { return entityMap[s]; });
}
var whiteRe = /\s*/;
var spaceRe = /\s+/;
var equalsRe = /\s*=/;
var curlyRe = /\s*\}/;
var tagRe = /#|\^|\/|>|\{|&|=|!/;
function parseTemplate(template, tags) {
if (!template) return [];
var sections = [];
var tokens = [];
var spaces = [];
var hasTag = false;
var nonSpace = false;
var openingTagRe, closingTagRe, closingCurlyRe;
function compileTags(tagsToCompile) {
if (typeof tagsToCompile === 'string') tagsToCompile = tagsToCompile.split(spaceRe, 2);
if (!Array.isArray(tagsToCompile) || tagsToCompile.length !== 2) throw new Error('Invalid tags: ' + tagsToCompile);
openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*');
closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1]));
closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1]));
}
compileTags(tags || mustache.tags);
var scanner = new Scanner(template);
var start, type, value, chr, token, openSection;
while (!scanner.eos()) {
start = scanner.pos;
value = scanner.scanUntil(openingTagRe);
if (value) {
for (var i = 0, valueLength = value.length; i < valueLength; ++i) {
chr = value.charAt(i);
if (isWhitespace(chr)) spaces.push(tokens.length);
else nonSpace = true;
tokens.push(['text', chr, start, start + 1]);
start += 1;
if (chr === '\n') stripSpace();
}
}
if (!scanner.scan(openingTagRe)) break;
hasTag = true;
type = scanner.scan(tagRe) || 'name';
scanner.scan(whiteRe);
if (type === '=') {
value = scanner.scanUntil(equalsRe);
scanner.scan(equalsRe);
scanner.scanUntil(closingTagRe);
} else if (type === '{') {
value = scanner.scanUntil(closingCurlyRe);
scanner.scan(curlyRe);
scanner.scanUntil(closingTagRe);
type = '&';
} else value = scanner.scanUntil(closingTagRe);
if (!scanner.scan(closingTagRe)) throw new Error('Unclosed tag at ' + scanner.pos);
token = [type, value, start, scanner.pos];
tokens.push(token);
if (type === '#' || type === '^') sections.push(token);
else if (type === '/') {
openSection = sections.pop();
if (!openSection) throw new Error('Unopened section "' + value + '" at ' + start);
if (openSection[1] !== value) throw new Error('Unclosed section "' + openSection[1] + '" at ' + start);
} else if (type === 'name' || type === '{' || type === '&') nonSpace = true;
else if (type === '=') compileTags(value);
}
stripSpace();
openSection = sections.pop();
if (openSection) throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos);
return nestTokens(squashTokens(tokens));
function stripSpace() {
if (hasTag && !nonSpace) while (spaces.length) delete tokens[spaces.pop()];
else spaces = [];
hasTag = false;
nonSpace = false;
}
}
function squashTokens(tokens) {
var squashedTokens = [];
var token, lastToken;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
token = tokens[i];
if (token) {
if (token[0] === 'text' && lastToken && lastToken[0] === 'text') {
lastToken[1] += token[1];
lastToken[3] = token[3];
} else {
squashedTokens.push(token);
lastToken = token;
}
}
}
return squashedTokens;
}
function nestTokens(tokens) {
var nestedTokens = [];
var collector = nestedTokens;
var sections = [];
var token, section;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
token = tokens[i];
switch (token[0]) {
case '#':
case '^':
collector.push(token);
sections.push(token);
collector = token[4] = [];
break;
case '/':
section = sections.pop();
section[5] = token[2];
collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
break;
default:
collector.push(token);
}
}
return nestedTokens;
}
function Scanner(string) {
this.string = string;
this.tail = string;
this.pos = 0;
}
Scanner.prototype.eos = function eos() {
return this.tail === '';
};
Scanner.prototype.scan = function scan(re) {
var match = this.tail.match(re);
if (!match || match.index !== 0) return '';
var string = match[0];
this.tail = this.tail.substring(string.length);
this.pos += string.length;
return string;
};
Scanner.prototype.scanUntil = function scanUntil(re) {
var index = this.tail.search(re), match;
switch (index) {
case -1: match = this.tail; this.tail = ''; break;
case 0: match = ''; break;
default: match = this.tail.substring(0, index); this.tail = this.tail.substring(index);
}
this.pos += match.length;
return match;
};
function Context(view, parentContext) {
this.view = view;
this.cache = { '.': this.view };
this.parent = parentContext;
}
Context.prototype.push = function push(view) {
return new Context(view, this);
};
Context.prototype.lookup = function lookup(name) {
var cache = this.cache;
var value;
if (cache.hasOwnProperty(name)) value = cache[name];
else {
var context = this, intermediateValue, names, index, lookupHit = false;
while (context) {
if (name.indexOf('.') > 0) {
intermediateValue = context.view;
names = name.split('.');
index = 0;
while (intermediateValue != null && index < names.length) {
if (index === names.length - 1) lookupHit = hasProperty(intermediateValue, names[index]) || primitiveHasOwnProperty(intermediateValue, names[index]);
intermediateValue = intermediateValue[names[index++]];
}
} else {
intermediateValue = context.view[name];
lookupHit = hasProperty(context.view, name);
}
if (lookupHit) { value = intermediateValue; break; }
context = context.parent;
}
cache[name] = value;
}
if (isFunction(value)) value = value.call(this.view);
return value;
};
function Writer() {
this.templateCache = { _cache: {}, set: function(key, value) { this._cache[key] = value; }, get: function(key) { return this._cache[key]; }, clear: function() { this._cache = {}; } };
}
Writer.prototype.clearCache = function clearCache() {
if (typeof this.templateCache !== 'undefined') this.templateCache.clear();
};
Writer.prototype.parse = function parse(template, tags) {
var cache = this.templateCache;
var cacheKey = template + ':' + (tags || mustache.tags).join(':');
var cached = cache.get(cacheKey);
if (cached == null) { cached = parseTemplate(template, tags); cache.set(cacheKey, cached); }
return cached;
};
Writer.prototype.render = function render(template, view, partials, tags) {
var tokens = this.parse(template, tags);
var context = (view instanceof Context) ? view : new Context(view);
return this.renderTokens(tokens, context, partials, template, tags);
};
Writer.prototype.renderTokens = function renderTokens(tokens, context, partials, originalTemplate, tags) {
var buffer = '';
var token, symbol, value;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
value = undefined;
token = tokens[i];
symbol = token[0];
if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate);
else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate, tags);
else if (symbol === '>') value = this.renderPartial(token, context, partials, tags);
else if (symbol === '&') value = this.unescapedValue(token, context);
else if (symbol === 'name') value = this.escapedValue(token, context, tags);
else if (symbol === 'text') value = this.rawValue(token);
if (value !== undefined) buffer += value;
}
return buffer;
};
Writer.prototype.renderSection = function renderSection(token, context, partials, originalTemplate) {
var self = this;
var buffer = '';
var value = context.lookup(token[1]);
function subRender(template) { return self.render(template, context, partials); }
if (!value) return;
if (isArray(value)) {
for (var j = 0, valueLength = value.length; j < valueLength; ++j) buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate);
} else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') {
buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate);
} else if (isFunction(value)) {
if (typeof originalTemplate !== 'string') throw new Error('Cannot use higher-order sections without the original template');
value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender);
if (value != null) buffer += value;
} else buffer += this.renderTokens(token[4], context, partials, originalTemplate);
return buffer;
};
Writer.prototype.renderInverted = function renderInverted(token, context, partials, originalTemplate, tags) {
var value = context.lookup(token[1]);
if (!value || (isArray(value) && value.length === 0)) return this.renderTokens(token[4], context, partials, originalTemplate, tags);
};
Writer.prototype.renderPartial = function renderPartial(token, context, partials, tags) {
if (!partials) return;
var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
if (value != null) return this.render(value, context, partials, tags);
};
Writer.prototype.unescapedValue = function unescapedValue(token, context) {
var value = context.lookup(token[1]);
if (value != null) return value;
};
Writer.prototype.escapedValue = function escapedValue(token, context, tags) {
var escape = this.getConfigEscape(tags) || mustache.escape;
var value = context.lookup(token[1]);
if (value != null) return typeof value === 'number' && escape === mustache.escape ? String(value) : escape(value);
};
Writer.prototype.rawValue = function rawValue(token) {
return token[1];
};
Writer.prototype.getConfigEscape = function getConfigEscape(tags) {
if (tags && typeof tags === 'object' && !isArray(tags)) return tags.escape;
};
var mustache = {
name: 'mustache.js', version: '4.2.0', tags: ['{{', '}}'],
clearCache: undefined, escape: escapeHtml, parse: undefined, render: undefined,
Scanner: undefined, Context: undefined, Writer: undefined,
set templateCache(cache) { defaultWriter.templateCache = cache; },
get templateCache() { return defaultWriter.templateCache; }
};
var defaultWriter = new Writer();
mustache.clearCache = function clearCache() { return defaultWriter.clearCache(); };
mustache.parse = function parse(template, tags) { return defaultWriter.parse(template, tags); };
mustache.render = function render(template, view, partials, tags) {
if (typeof template !== 'string') throw new TypeError('Invalid template! Template should be a "string" but "' + typeStr(template) + '" was given as the first argument for mustache#render(template, view, partials)');
return defaultWriter.render(template, view, partials, tags);
};
mustache.Scanner = Scanner;
mustache.Context = Context;
mustache.Writer = Writer;
return mustache;
}));
</script>
<script src="app.js?v=8"></script>
</body> </body>
</html> </html>

1
src/static/mustache.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -214,6 +214,8 @@ body {
.receipt-preview .text-center { text-align: center; } .receipt-preview .text-center { text-align: center; }
.receipt-preview .text-right { text-align: right; } .receipt-preview .text-right { text-align: right; }
.receipt-preview .text-bold { font-weight: bold; } .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-small { font-size: 10px; }
.receipt-preview .text-large { font-size: 14px; } .receipt-preview .text-large { font-size: 14px; }
.receipt-preview .text-xlarge { font-size: 18px; } .receipt-preview .text-xlarge { font-size: 18px; }
@ -234,6 +236,15 @@ body {
resize: vertical; 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 { #schemaDisplay {
background: #f5f5f5; background: #f5f5f5;
padding: 16px; padding: 16px;

28
start-server.sh Executable file
View File

@ -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"