Compare commits

...

9 Commits

Author SHA1 Message Date
Developer
fff24a08ed feat: load example templates on startup 2026-02-12 08:12:01 +00:00
Developer
82e4fc91a4 feat: add web configuration interface 2026-02-12 08:10:13 +00:00
Developer
0235473254 feat: add Hono HTTP server with REST API 2026-02-12 08:07:58 +00:00
Developer
002f6dc8f3 feat: add printer connector with job queue 2026-02-12 08:04:20 +00:00
Developer
400b716623 feat: add automatic schema extraction from templates 2026-02-12 08:01:10 +00:00
Developer
6cc292d61a feat: add ESC/POS renderer 2026-02-12 08:01:10 +00:00
Developer
c561dae461 feat: add YAML template parser 2026-02-12 07:57:27 +00:00
Developer
72b1d357e8 feat: add template type definitions 2026-02-12 07:55:47 +00:00
Developer
ac7248ec07 chore: initialize bun project with dependencies 2026-02-12 07:54:17 +00:00
21 changed files with 1612 additions and 5 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

20
TODO.md Normal file
View File

@ -0,0 +1,20 @@
# Receipt Printer 实施进度
## 任务列表
- [ ] 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
## 当前任务
Task 1: 项目初始化
## 已完成
(暂无)

46
bun.lock Normal file
View File

@ -0,0 +1,46 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "receipt-printer",
"dependencies": {
"hono": "^4.11.9",
"js-yaml": "^4.1.1",
"mustache": "^4.2.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/js-yaml": "^4.0.9",
"@types/mustache": "^4.2.6",
"bun-types": "^1.3.9",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/mustache": ["@types/mustache@4.2.6", "", {}, "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

View File

@ -10,14 +10,15 @@
"test": "bun test"
},
"dependencies": {
"hono": "^4.0.0",
"js-yaml": "^4.1.0",
"hono": "^4.11.9",
"js-yaml": "^4.1.1",
"mustache": "^4.2.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/js-yaml": "^4.0.9",
"@types/mustache": "^4.2.5",
"bun-types": "latest"
"@types/mustache": "^4.2.6",
"bun-types": "^1.3.9"
},
"keywords": [
"receipt",
@ -26,5 +27,9 @@
"thermal-printer",
"80mm"
],
"license": "MIT"
"license": "MIT",
"private": true,
"peerDependencies": {
"typescript": "^5"
}
}

115
src/api/routes.ts Normal file
View File

@ -0,0 +1,115 @@
import { Hono } from 'hono';
import { parseTemplate } from '../engine/parser';
import { renderTemplate } from '../engine/render';
import { extractSchema } from '../engine/schema';
import { PrinterConnector } from '../printer/connector';
import type { Template } from '../types/template';
const templates = new Map<string, Template>();
let printerConnector: PrinterConnector | null = null;
function getPrinter(): PrinterConnector {
if (!printerConnector) {
const ip = process.env.PRINTER_IP || '192.168.1.100';
const port = parseInt(process.env.PRINTER_PORT || '9100');
printerConnector = new PrinterConnector({ ip, port });
}
return printerConnector;
}
const api = new Hono();
// 模板管理
api.get('/templates', (c) => {
const list = Array.from(templates.values()).map(t => ({
id: t.id,
name: t.name,
description: t.description,
}));
return c.json({ success: true, templates: list });
});
api.get('/templates/:id', (c) => {
const id = c.req.param('id');
const template = templates.get(id);
if (!template) {
return c.json({ success: false, error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${id}' not found` }}, 404);
}
return c.json({ success: true, template });
});
api.post('/templates', async (c) => {
const body = await c.req.json();
if (!body.id || !body.config) {
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'Missing id or config' }}, 400);
}
try {
const config = typeof body.config === 'string' ? parseTemplate(body.config) : body.config;
const template: Template = { ...config, id: body.id };
templates.set(body.id, template);
return c.json({ success: true, template: { id: template.id, name: template.name }}, 201);
} catch (error) {
return c.json({ success: false, error: { code: 'INVALID_TEMPLATE', message: String(error) }}, 400);
}
});
api.get('/templates/:id/schema', (c) => {
const id = c.req.param('id');
const template = templates.get(id);
if (!template) {
return c.json({ success: false, error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${id}' not found` }}, 404);
}
const { schema, example } = extractSchema(template);
return c.json({ success: true, templateId: id, schema, example });
});
// 打印
api.post('/print/:templateId', async (c) => {
const templateId = c.req.param('templateId');
const template = templates.get(templateId);
if (!template) {
return c.json({ success: false, error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${templateId}' not found` }}, 404);
}
const body = await c.req.json();
const { data = {} } = body;
try {
const escData = renderTemplate(template, data);
const printer = getPrinter();
const jobId = printer.queue(escData);
return c.json({ success: true, jobId, status: 'queued', estimatedTime: '5s' });
} catch (error) {
return c.json({ success: false, error: { code: 'RENDER_ERROR', message: String(error) }}, 500);
}
});
// 打印机状态
api.get('/printer/status', async (c) => {
const printer = getPrinter();
const status = await printer.getStatus();
return c.json({
success: true,
printer: {
ip: process.env.PRINTER_IP || '192.168.1.100',
port: parseInt(process.env.PRINTER_PORT || '9100'),
...status,
queueLength: printer.getQueueLength()
}
});
});
api.post('/printer/test', (c) => {
const printer = getPrinter();
const testData = new Uint8Array([
0x1B, 0x40,
0x1B, 0x61, 0x01,
...new TextEncoder().encode('Printer Test Page\n'),
...new TextEncoder().encode('================\n'),
0x1B, 0x61, 0x00,
0x0A, 0x0A,
0x1D, 0x56, 0x00,
]);
const jobId = printer.queue(testData);
return c.json({ success: true, jobId, message: 'Test page queued' });
});
export { api as apiRoutes };

30
src/config/loader.ts Normal file
View File

@ -0,0 +1,30 @@
import { parseTemplate } from '../engine/parser';
import type { Template } from '../types/template';
export async function loadExampleTemplates(): Promise<Map<string, Template>> {
const templates = new Map<string, Template>();
const examples = [
'daily-todo.yaml',
'food-order-simple.yaml',
'fancy-receipt.yaml',
'ticket-list.yaml',
'long-text.yaml'
];
for (const filename of examples) {
try {
const file = Bun.file(`./templates/examples/${filename}`);
if (await file.exists()) {
const content = await file.text();
const template = parseTemplate(content);
templates.set(template.id, template);
console.log(`✅ Loaded example template: ${template.name}`);
}
} catch (err) {
console.error(`❌ Failed to load ${filename}:`, err);
}
}
return templates;
}

84
src/engine/escpos.ts Normal file
View File

@ -0,0 +1,84 @@
// ESC/POS 指令常量
export const ESC = 0x1B;
export const GS = 0x1D;
export const LF = 0x0A;
export const CR = 0x0D;
// 初始化打印机
export function initialize(): Uint8Array {
return new Uint8Array([ESC, 0x40]);
}
// 切纸
export function cut(): Uint8Array {
return new Uint8Array([GS, 0x56, 0x00]);
}
// 文本编码
export function text(str: string): Uint8Array {
const encoder = new TextEncoder();
return encoder.encode(str);
}
// 换行
export function newline(): Uint8Array {
return new Uint8Array([LF]);
}
// 加粗
export function bold(on: boolean): Uint8Array {
return new Uint8Array([ESC, 0x45, on ? 1 : 0]);
}
// 下划线
export function underline(on: boolean): Uint8Array {
return new Uint8Array([ESC, 0x2D, on ? 1 : 0]);
}
// 对齐
export function align(align: 'left' | 'center' | 'right'): Uint8Array {
const n = align === 'left' ? 0 : align === 'center' ? 1 : 2;
return new Uint8Array([ESC, 0x61, n]);
}
// 字体大小
export function fontSize(size: 'small' | 'normal' | 'large' | 'xlarge'): Uint8Array {
let n = 0;
switch (size) {
case 'small': n = 0; break;
case 'normal': n = 0; break;
case 'large': n = 0x11; break;
case 'xlarge': n = 0x22; break;
}
return new Uint8Array([GS, 0x21, n]);
}
// 恢复默认字体大小
export function resetFontSize(): Uint8Array {
return new Uint8Array([GS, 0x21, 0]);
}
// 打印条码 (CODE128)
export function barcodeCode128(data: string, height: number = 64): Uint8Array {
const encoder = new TextEncoder();
const dataBytes = encoder.encode(data);
return new Uint8Array([
GS, 0x68, height,
GS, 0x6B, 0x49, dataBytes.length,
...dataBytes,
0
]);
}
// 打印 QR 码
export function qrCode(data: string, size: number = 3): Uint8Array {
const encoder = new TextEncoder();
const dataBytes = encoder.encode(data);
const len = dataBytes.length + 3;
return new Uint8Array([
GS, 0x28, 0x6B, 0x03, 0x00, 0x31, 0x43, size,
GS, 0x28, 0x6B, len & 0xFF, (len >> 8) & 0xFF, 0x31, 0x50, 0x30,
...dataBytes,
GS, 0x28, 0x6B, 0x03, 0x00, 0x31, 0x51, 0x30
]);
}

26
src/engine/parser.ts Normal file
View File

@ -0,0 +1,26 @@
import YAML from 'js-yaml';
import type { Template } from '../types/template';
export function parseTemplate(yamlContent: string): Template {
try {
const parsed = YAML.load(yamlContent) as any;
// 基础验证
if (!parsed.name || !parsed.id) {
throw new Error('Template must have name and id');
}
if (!Array.isArray(parsed.blocks)) {
throw new Error('Template must have blocks array');
}
return parsed as Template;
} catch (error) {
throw new Error(`Failed to parse template: ${error}`);
}
}
export function loadTemplate(filePath: string): Template {
const content = Bun.file(filePath).text();
return parseTemplate(content);
}

73
src/engine/render.ts Normal file
View File

@ -0,0 +1,73 @@
import Mustache from 'mustache';
import type { Template, Block } from '../types/template';
import * as escpos from './escpos';
export function renderTemplate(template: Template, data: any): Uint8Array {
const buffers: Uint8Array[] = [];
// 初始化打印机
buffers.push(escpos.initialize());
// 渲染每个 block
for (const block of template.blocks) {
buffers.push(renderBlock(block, data, template.defaults));
}
// 切纸
buffers.push(escpos.cut());
return mergeBuffers(buffers);
}
function renderBlock(block: Block, data: any, defaults: any = {}): Uint8Array {
// 根据 block 类型渲染
switch (block.type) {
case 'text':
return renderTextBlock(block, data);
case 'divider':
return renderDividerBlock(block);
case 'space':
return renderSpaceBlock(block);
default:
console.warn(`Unknown block type: ${(block as any).type}`);
return new Uint8Array(0);
}
}
function renderTextBlock(block: any, data: any): Uint8Array {
const buffers: Uint8Array[] = [];
buffers.push(escpos.align(block.align || 'left'));
buffers.push(escpos.bold(block.bold || false));
const content = Mustache.render(block.content, data);
buffers.push(escpos.text(content));
buffers.push(escpos.newline());
buffers.push(escpos.bold(false));
buffers.push(escpos.align('left'));
return mergeBuffers(buffers);
}
function renderDividerBlock(block: any): Uint8Array {
const char = block.char || '-';
const line = char.repeat(48);
return mergeBuffers([escpos.text(line), escpos.newline()]);
}
function renderSpaceBlock(block: any): Uint8Array {
const lines = block.lines || 1;
const buffers: Uint8Array[] = [];
for (let i = 0; i < lines; i++) {
buffers.push(escpos.newline());
}
return mergeBuffers(buffers);
}
function mergeBuffers(buffers: Uint8Array[]): Uint8Array {
const totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const buf of buffers) {
result.set(buf, offset);
offset += buf.length;
}
return result;
}

98
src/engine/schema.ts Normal file
View File

@ -0,0 +1,98 @@
import type { Template, Block } from '../types/template';
interface JSONSchema {
type: string;
properties: Record<string, any>;
required?: string[];
}
export function extractSchema(template: Template): { schema: JSONSchema; example: any } {
const variables = new Set<string>();
const arrayVariables = new Set<string>();
function extractFromString(str: string) {
const matches = str.match(/\{\{([^}]+)\}\}/g);
if (matches) {
matches.forEach(match => {
const varName = match.replace(/\{\{|\}\}/g, '').trim().split('.')[0];
variables.add(varName);
});
}
}
function extractFromBlock(block: Block) {
switch (block.type) {
case 'text':
extractFromString(block.content);
break;
case 'row':
block.columns.forEach(col => {
if (col.content) extractFromString(col.content);
if (col.header) extractFromString(col.header);
});
break;
case 'table':
extractFromString(block.data);
block.columns.forEach(col => {
if (col.header) extractFromString(col.header);
});
break;
case 'list':
extractFromString(block.data);
block.itemTemplate.forEach(extractFromBlock);
break;
case 'divider':
break;
case 'image':
extractFromString(block.src);
break;
case 'barcode':
extractFromString(block.data);
break;
case 'space':
break;
}
}
template.blocks.forEach(extractFromBlock);
// 构建 JSON Schema
const properties: Record<string, any> = {};
variables.forEach(varName => {
if (varName === 'items' || varName === 'tasks' || varName === 'tickets') {
properties[varName] = { type: 'array', description: `${varName} list` };
arrayVariables.add(varName);
} else if (varName.endsWith('Count') || varName === 'quantity' || varName === 'total') {
properties[varName] = { type: 'number', description: varName };
} else {
properties[varName] = { type: 'string', description: varName };
}
});
const schema: JSONSchema = {
type: 'object',
properties
};
// 生成示例数据
const example: any = {};
variables.forEach(varName => {
if (arrayVariables.has(varName)) {
example[varName] = [
{ name: 'Item 1', value: 'value1' },
{ name: 'Item 2', value: 'value2' }
];
} else if (properties[varName].type === 'number') {
example[varName] = 42;
} else if (varName.includes('date') || varName.includes('Date')) {
example[varName] = '2025-02-12';
} else if (varName.includes('time') || varName.includes('Time')) {
example[varName] = '14:30:00';
} else {
example[varName] = varName;
}
});
return { schema, example };
}

134
src/printer/connector.ts Normal file
View File

@ -0,0 +1,134 @@
interface PrinterConfig {
ip: string;
port: number;
timeout?: number;
}
interface PrintJob {
id: string;
data: Uint8Array;
status: 'pending' | 'printing' | 'completed' | 'failed';
createdAt: Date;
startedAt?: Date;
completedAt?: Date;
error?: string;
}
export class PrinterConnector {
private config: PrinterConfig;
private queueItems: PrintJob[] = [];
private currentJob: PrintJob | null = null;
private connected = false;
private processing = false;
constructor(config: PrinterConfig) {
this.config = {
timeout: 5000,
...config
};
}
isConnected(): boolean {
return this.connected;
}
getQueueLength(): number {
return this.queueItems.length + (this.currentJob ? 1 : 0);
}
queue(data: Uint8Array): string {
const job: PrintJob = {
id: `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
data,
status: 'pending',
createdAt: new Date()
};
this.queueItems.push(job);
this.processQueue();
return job.id;
}
getJob(jobId: string): PrintJob | undefined {
if (this.currentJob?.id === jobId) return this.currentJob;
return this.queueItems.find(j => j.id === jobId);
}
getAllJobs(): PrintJob[] {
return this.currentJob
? [this.currentJob, ...this.queueItems]
: [...this.queueItems];
}
cancelJob(jobId: string): boolean {
const index = this.queueItems.findIndex(j => j.id === jobId);
if (index >= 0 && this.queueItems[index].status === 'pending') {
this.queueItems[index].status = 'cancelled' as any;
this.queueItems.splice(index, 1);
return true;
}
return false;
}
async processQueue(): Promise<void> {
if (this.processing || this.queueItems.length === 0) return;
this.processing = true;
while (this.queueItems.length > 0) {
const job = this.queueItems.shift()!;
this.currentJob = job;
job.status = 'printing';
job.startedAt = new Date();
try {
await this.sendToPrinter(job.data);
job.status = 'completed';
job.completedAt = new Date();
} catch (error) {
job.status = 'failed';
job.error = String(error);
console.error(`Print job ${job.id} failed:`, error);
}
}
this.currentJob = null;
this.processing = false;
}
private async sendToPrinter(data: Uint8Array): Promise<void> {
return new Promise((resolve, reject) => {
const socket = new (Bun as any).connect({
hostname: this.config.ip,
port: this.config.port,
});
socket.then((conn: any) => {
this.connected = true;
conn.write(data);
conn.end();
setTimeout(() => {
this.connected = false;
resolve();
}, 1000);
}).catch((err: any) => {
this.connected = false;
reject(err);
});
});
}
async getStatus(): Promise<{ online: boolean; paperStatus: string }> {
try {
const socket = await (Bun as any).connect({
hostname: this.config.ip,
port: this.config.port,
});
socket.end();
return { online: true, paperStatus: 'ok' };
} catch {
return { online: false, paperStatus: 'unknown' };
}
}
}

28
src/server.ts Normal file
View File

@ -0,0 +1,28 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { apiRoutes } from './api/routes';
import { loadExampleTemplates } from './config/loader';
// 加载示例模板
const templates = await loadExampleTemplates();
console.log(`📋 Loaded ${templates.size} example templates`);
const app = new Hono();
// 静态文件服务Web 界面)
app.use('/*', serveStatic({ root: './src/static' }));
// API 路由
app.route('/api', apiRoutes);
// 健康检查
app.get('/health', (c) => c.json({ status: 'ok' }));
const port = process.env.PORT || 3000;
console.log(`🖨️ Receipt Printer Server starting on http://localhost:${port}`);
export default {
port,
fetch: app.fetch,
};

227
src/static/app.js Normal file
View File

@ -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 '<p style="color: #999;">配置无效</p>';
}
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 `<div class="${style.join(' ')}">${escapeHtml(content)}</div>`;
}
case 'divider': {
const char = block.char || '-';
return `<div class="divider">${char.repeat(48)}</div>`;
}
case 'row': {
const cols = block.columns.map(col => {
const content = col.content ? Mustache.render(col.content, data) : '';
return `<span>${escapeHtml(content)}</span>`;
});
return `<div style="display: flex; justify-content: space-between;">${cols.join('')}</div>`;
}
case 'space': {
return '<br>'.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');
});
});
}

88
src/static/index.html Normal file
View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt Printer - 小票打印机配置</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
</head>
<body>
<div class="app">
<header class="header">
<h1>🖨️ Receipt Printer</h1>
<div class="printer-status" id="printerStatus">
<span class="status-dot offline"></span>
<span class="status-text">离线</span>
</div>
</header>
<div class="main">
<!-- 左侧:模板列表 -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>模板</h2>
<button class="btn btn-primary" id="newTemplate">+ 新建</button>
</div>
<ul class="template-list" id="templateList">
<!-- 动态加载 -->
</ul>
</aside>
<!-- 中间:编辑器 -->
<div class="editor">
<div class="editor-header">
<input type="text" id="templateName" placeholder="模板名称" class="template-name">
<div class="editor-actions">
<button class="btn" id="saveTemplate">保存</button>
<button class="btn btn-danger" id="deleteTemplate">删除</button>
</div>
</div>
<div class="editor-body">
<textarea id="yamlEditor"></textarea>
</div>
<div class="editor-toolbar">
<span>插入:</span>
<button class="btn btn-sm" data-block="text">文本</button>
<button class="btn btn-sm" data-block="row">多列</button>
<button class="btn btn-sm" data-block="table">表格</button>
<button class="btn btn-sm" data-block="list">列表</button>
<button class="btn btn-sm" data-block="divider">分隔线</button>
<button class="btn btn-sm" data-block="barcode">条码</button>
<button class="btn btn-sm" data-block="space">空行</button>
</div>
</div>
<!-- 右侧:预览和数据 -->
<aside class="preview-panel">
<div class="preview-tabs">
<button class="tab-btn active" data-tab="preview">预览</button>
<button class="tab-btn" data-tab="data">测试数据</button>
<button class="tab-btn" data-tab="schema">Schema</button>
</div>
<div class="tab-content" id="previewTab">
<div class="receipt-preview" id="receiptPreview">
<!-- 实时预览 -->
</div>
</div>
<div class="tab-content hidden" id="dataTab">
<textarea id="dataEditor" placeholder='{"key": "value"}'></textarea>
<button class="btn btn-primary" id="testPrint">打印测试</button>
</div>
<div class="tab-content hidden" id="schemaTab">
<pre id="schemaDisplay"></pre>
</div>
</aside>
</div>
</div>
<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/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>
</body>
</html>

301
src/static/style.css Normal file
View File

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

109
src/types/template.ts Normal file
View File

@ -0,0 +1,109 @@
// 对齐方式
export type Align = 'left' | 'center' | 'right';
// 字体大小
export type FontSize = 'small' | 'normal' | 'large' | 'xlarge';
// 条码格式
export type BarcodeFormat = 'CODE128' | 'QR' | 'EAN13';
// 基础样式
export interface BlockStyle {
align?: Align;
fontSize?: FontSize;
bold?: boolean;
italic?: boolean;
underline?: boolean;
lineHeight?: number;
marginTop?: number;
marginBottom?: number;
}
// 列定义
export interface Column {
content?: string;
align?: Align;
width?: string | number;
bold?: boolean;
header?: string;
}
// Block 类型
export interface TextBlock extends BlockStyle {
type: 'text';
content: string;
}
export interface RowBlock extends BlockStyle {
type: 'row';
columns: Column[];
}
export interface TableBlock extends BlockStyle {
type: 'table';
columns: Column[];
data: string; // mustache expression
}
export interface ListBlock extends BlockStyle {
type: 'list';
data: string; // mustache expression
itemTemplate: Block[];
}
export interface DividerBlock extends BlockStyle {
type: 'divider';
char?: string;
}
export interface ImageBlock extends BlockStyle {
type: 'image';
src: string; // mustache expression or URL
maxWidth?: number;
}
export interface BarcodeBlock extends BlockStyle {
type: 'barcode';
format: BarcodeFormat;
data: string; // mustache expression
height?: number;
}
export interface SpaceBlock extends BlockStyle {
type: 'space';
lines?: number;
}
export type Block =
| TextBlock
| RowBlock
| TableBlock
| ListBlock
| DividerBlock
| ImageBlock
| BarcodeBlock
| SpaceBlock;
// 页面设置
export interface PageConfig {
marginTop?: number;
marginBottom?: number;
}
// 默认样式
export interface DefaultStyle {
fontSize?: FontSize;
lineHeight?: number;
marginBottom?: number;
}
// 完整模板
export interface Template {
id: string;
name: string;
description?: string;
width: string; // e.g., "80mm"
page?: PageConfig;
defaults?: DefaultStyle;
blocks: Block[];
}

25
tests/connector.test.ts Normal file
View File

@ -0,0 +1,25 @@
import { describe, it, expect } from 'bun:test';
import { PrinterConnector } from '../src/printer/connector';
describe('Printer Connector', () => {
it('should create connector with config', () => {
const connector = new PrinterConnector({
ip: '192.168.1.100',
port: 9100
});
expect(connector).toBeDefined();
expect(connector.isConnected()).toBe(false);
});
it('should queue print jobs', () => {
const connector = new PrinterConnector({
ip: '192.168.1.100',
port: 9100
});
const data = new Uint8Array([0x1B, 0x40]);
const jobId = connector.queue(data);
expect(jobId).toMatch(/^job_/);
expect(connector.getQueueLength()).toBe(1);
});
});

67
tests/parser.test.ts Normal file
View File

@ -0,0 +1,67 @@
import { describe, it, expect } from 'bun:test';
import { parseTemplate } from '../src/engine/parser';
describe('Template Parser', () => {
it('should parse simple text template', () => {
const yaml = `
name: "Test Template"
id: "test"
width: 80mm
blocks:
- type: text
content: "Hello World"
align: center
`;
const template = parseTemplate(yaml);
expect(template.name).toBe('Test Template');
expect(template.blocks).toHaveLength(1);
expect(template.blocks[0].type).toBe('text');
expect((template.blocks[0] as any).content).toBe('Hello World');
});
it('should parse row with columns', () => {
const yaml = `
name: "Row Test"
id: "row-test"
width: 80mm
blocks:
- type: row
columns:
- content: "Left"
align: left
width: 50%
- content: "Right"
align: right
width: 50%
`;
const template = parseTemplate(yaml);
const row = template.blocks[0] as any;
expect(row.type).toBe('row');
expect(row.columns).toHaveLength(2);
expect(row.columns[0].align).toBe('left');
});
it('should parse list with item template', () => {
const yaml = `
name: "List Test"
id: "list-test"
width: 80mm
blocks:
- type: list
data: "{{items}}"
itemTemplate:
- type: text
content: "{{name}}"
`;
const template = parseTemplate(yaml);
const list = template.blocks[0] as any;
expect(list.type).toBe('list');
expect(list.data).toBe('{{items}}');
expect(list.itemTemplate).toHaveLength(1);
});
it('should throw on invalid YAML', () => {
const yaml = 'invalid: [unclosed';
expect(() => parseTemplate(yaml)).toThrow();
});
});

52
tests/render.test.ts Normal file
View File

@ -0,0 +1,52 @@
import { describe, it, expect } from 'bun:test';
import { renderTemplate } from '../src/engine/render';
import type { Template } from '../src/types/template';
describe('Template Renderer', () => {
it('should render text block', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{ type: 'text', content: 'Hello World', align: 'center' }
]
};
const data = {};
const result = renderTemplate(template, data);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBeGreaterThan(0);
});
it('should substitute mustache variables', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{ type: 'text', content: 'Hello {{name}}' }
]
};
const data = { name: 'Alice' };
const result = renderTemplate(template, data);
const text = new TextDecoder().decode(result);
expect(text).toContain('Hello Alice');
});
it('should render divider', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{ type: 'divider', char: '=' }
]
};
const result = renderTemplate(template, {});
const text = new TextDecoder().decode(result);
expect(text).toContain('========');
});
});

57
tests/schema.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { describe, it, expect } from 'bun:test';
import { extractSchema } from '../src/engine/schema';
import type { Template } from '../src/types/template';
describe('Schema Extractor', () => {
it('should extract simple variables', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{ type: 'text', content: 'Hello {{name}}' },
{ type: 'text', content: 'Date: {{date}}' }
]
};
const { schema } = extractSchema(template);
expect(schema.properties.name).toEqual({ type: 'string', description: 'name' });
expect(schema.properties.date).toEqual({ type: 'string', description: 'date' });
});
it('should extract list variables', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{
type: 'list',
data: '{{items}}',
itemTemplate: [
{ type: 'text', content: '{{title}}' }
]
}
]
};
const { schema } = extractSchema(template);
expect(schema.properties.items).toEqual({ type: 'array', description: 'items list' });
expect(schema.properties.title).toEqual({ type: 'string', description: 'title' });
});
it('should generate example data', () => {
const template: Template = {
id: 'test',
name: 'Test',
width: '80mm',
blocks: [
{ type: 'text', content: '{{name}} - {{count}}' }
]
};
const { example } = extractSchema(template);
expect(example).toHaveProperty('name');
expect(example).toHaveProperty('count');
});
});

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}