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:
parent
2c18fca719
commit
a9bc4edc3f
24
TODO.md
24
TODO.md
@ -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
31
monitor.sh
Executable 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
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 = {'&':'&','<':'<','>':'>','"':'"',"'":''','/':'/','`':'`','=':'='};
|
||||||
|
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
1
src/static/mustache.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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
28
start-server.sh
Executable 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"
|
||||||
Loading…
x
Reference in New Issue
Block a user