58 KiB
Receipt Printer 实现计划
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 实现一个支持可视化模板配置和 REST API 的 80mm 小票打印系统
Architecture: 采用分层架构:模板引擎层(YAML解析/Mustache渲染/ESC-POS生成)、打印机驱动层(TCP连接/指令队列)、API服务层(Hono HTTP服务)、Web界面层(静态文件/YAML编辑器/实时预览)
Tech Stack: Bun + Hono + js-yaml + mustache + 原生 HTML/JS
目录
Phase 1: 核心模板引擎
Task 1: 项目初始化
Files:
- Create:
package.json - Create:
bun.lock - Create:
tsconfig.json - Create:
.gitignore
Step 1: 初始化 Bun 项目
运行:
cd receipt-printer
bun init -y
Step 2: 安装依赖
运行:
bun add hono js-yaml mustache
bun add -d @types/js-yaml @types/mustache bun-types
Step 3: 创建 tsconfig.json
创建 tsconfig.json:
{
"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"]
}
Step 4: 创建 .gitignore
创建 .gitignore:
node_modules/
dist/
.env
*.log
.DS_Store
Step 5: Commit
git add .
git commit -m "chore: initialize bun project with dependencies"
Task 2: 模板类型定义
Files:
- Create:
src/types/template.ts
Step 1: 定义 Block 类型
创建 src/types/template.ts:
// 对齐方式
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[];
}
Step 2: Commit
git add src/types/template.ts
git commit -m "feat: add template type definitions"
Task 3: YAML 模板解析器
Files:
- Create:
src/engine/parser.ts - Create:
tests/parser.test.ts
Step 1: 编写测试
创建 tests/parser.test.ts:
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();
});
});
Step 2: 运行测试确认失败
运行:
bun test tests/parser.test.ts
预期:FAIL - "parseTemplate is not defined"
Step 3: 实现解析器
创建 src/engine/parser.ts:
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);
}
Step 4: 运行测试确认通过
运行:
bun test tests/parser.test.ts
预期:PASS - 4/4 tests passed
Step 5: Commit
git add src/engine/parser.ts tests/parser.test.ts
git commit -m "feat: add YAML template parser"
Task 4: Schema 提取器
Files:
- Create:
src/engine/schema.ts - Create:
tests/schema.test.ts
Step 1: 编写测试
创建 tests/schema.test.ts:
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' });
expect(schema.properties.date).toEqual({ type: 'string' });
});
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' });
expect(schema.properties.title).toEqual({ type: 'string' });
});
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');
});
});
Step 2: 运行测试确认失败
运行:
bun test tests/schema.test.ts
预期:FAIL
Step 3: 实现 Schema 提取器
创建 src/engine/schema.ts:
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 };
}
Step 4: 运行测试确认通过
运行:
bun test tests/schema.test.ts
预期:PASS
Step 5: Commit
git add src/engine/schema.ts tests/schema.test.ts
git commit -m "feat: add automatic schema extraction from templates"
Task 5: ESC/POS 生成器
Files:
- Create:
src/engine/render.ts - Create:
src/engine/escpos.ts - Create:
tests/render.test.ts
Step 1: 编写 ESC/POS 指令集
创建 src/engine/escpos.ts:
// 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; // 2x width, 2x height
case 'xlarge': n = 0x22; break; // 3x
}
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, // CODE128
...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, // 设置 QR 大小
GS, 0x28, 0x6B, len & 0xFF, (len >> 8) & 0xFF, 0x31, 0x50, 0x30, // 存储数据
...dataBytes,
GS, 0x28, 0x6B, 0x03, 0x00, 0x31, 0x51, 0x30 // 打印
]);
}
Step 2: 编写渲染器测试
创建 tests/render.test.ts:
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('========');
});
});
Step 3: 运行测试确认失败
运行:
bun test tests/render.test.ts
预期:FAIL
Step 4: 实现渲染器
创建 src/engine/render.ts:
import Mustache from 'mustache';
import type { Template, Block, ListBlock, TableBlock, RowBlock } 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());
// 合并所有 buffer
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;
}
function renderBlock(block: Block, data: any, defaults: any = {}): Uint8Array {
const buffers: Uint8Array[] = [];
// 应用间距
const marginTop = block.marginTop ?? defaults?.marginBottom ?? 0;
for (let i = 0; i < marginTop; i++) {
buffers.push(escpos.newline());
}
switch (block.type) {
case 'text':
buffers.push(renderTextBlock(block, data));
break;
case 'row':
buffers.push(renderRowBlock(block as RowBlock, data));
break;
case 'table':
buffers.push(renderTableBlock(block as TableBlock, data));
break;
case 'list':
buffers.push(renderListBlock(block as ListBlock, data, defaults));
break;
case 'divider':
buffers.push(renderDividerBlock(block));
break;
case 'space':
buffers.push(renderSpaceBlock(block));
break;
case 'barcode':
buffers.push(renderBarcodeBlock(block));
break;
default:
console.warn(`Unknown block type: ${(block as any).type}`);
}
// 应用底部间距
const marginBottom = block.marginBottom ?? defaults?.marginBottom ?? 0;
for (let i = 0; i < marginBottom; i++) {
buffers.push(escpos.newline());
}
// 合并
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;
}
function renderTextBlock(block: any, data: any): Uint8Array {
const buffers: Uint8Array[] = [];
// 对齐
buffers.push(escpos.align(block.align || 'left'));
// 加粗
buffers.push(escpos.bold(block.bold || false));
// 下划线
buffers.push(escpos.underline(block.underline || false));
// 字体大小
if (block.fontSize && block.fontSize !== 'normal') {
buffers.push(escpos.fontSize(block.fontSize));
}
// 内容(渲染 mustache)
const content = Mustache.render(block.content, data);
buffers.push(escpos.text(content));
buffers.push(escpos.newline());
// 恢复默认
buffers.push(escpos.resetFontSize());
buffers.push(escpos.bold(false));
buffers.push(escpos.underline(false));
buffers.push(escpos.align('left'));
return mergeBuffers(buffers);
}
function renderRowBlock(block: RowBlock, data: any): Uint8Array {
const buffers: Uint8Array[] = [];
// 计算可用宽度(80mm 约 48 个汉字或 96 个英文字符)
const maxChars = 48;
// 渲染每一列
const columns: string[] = [];
for (const col of block.columns) {
let content = col.content ? Mustache.render(col.content, data) : '';
columns.push(content);
}
// 简单实现:左右两列分别对齐
if (block.columns.length === 2) {
const left = columns[0];
const right = columns[1];
const spaces = Math.max(0, maxChars - left.length - right.length);
const line = left + ' '.repeat(spaces) + right;
buffers.push(escpos.text(line));
buffers.push(escpos.newline());
} else {
// 单列实现
for (let i = 0; i < columns.length; i++) {
const col = block.columns[i];
buffers.push(escpos.align(col.align || 'left'));
if (col.bold) buffers.push(escpos.bold(true));
buffers.push(escpos.text(columns[i]));
buffers.push(escpos.newline());
buffers.push(escpos.bold(false));
}
}
return mergeBuffers(buffers);
}
function renderTableBlock(block: TableBlock, data: any): Uint8Array {
const buffers: Uint8Array[] = [];
// 获取数据
const items = getNestedValue(data, block.data.replace(/\{\{|\}\}/g, '').trim()) || [];
for (const item of items) {
const lineParts: string[] = [];
for (const col of block.columns) {
// 这里简化处理,实际应该根据列宽格式化
const value = item[Object.keys(item).find((k, i) => block.columns[i] === col) || ''] || '';
lineParts.push(String(value));
}
buffers.push(escpos.text(lineParts.join(' ')));
buffers.push(escpos.newline());
}
return mergeBuffers(buffers);
}
function renderListBlock(block: ListBlock, data: any, defaults: any): Uint8Array {
const buffers: Uint8Array[] = [];
// 获取数据
const varName = block.data.replace(/\{\{|\}\}/g, '').trim();
const items = getNestedValue(data, varName) || [];
for (const item of items) {
for (const subBlock of block.itemTemplate) {
buffers.push(renderBlock(subBlock, { ...data, ...item, '.': item }, defaults));
}
}
return mergeBuffers(buffers);
}
function renderDividerBlock(block: any): Uint8Array {
const char = block.char || '-';
const line = char.repeat(48); // 80mm 约 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 renderBarcodeBlock(block: any): Uint8Array {
const buffers: Uint8Array[] = [];
buffers.push(escpos.align('center'));
const data = block.data.replace(/\{\{|\}\}/g, '').trim();
const height = block.height || 64;
if (block.format === 'QR') {
buffers.push(escpos.qrCode(data, 3));
} else {
buffers.push(escpos.barcodeCode128(data, height));
}
buffers.push(escpos.newline());
buffers.push(escpos.align('left'));
return mergeBuffers(buffers);
}
function getNestedValue(obj: any, path: string): any {
return path.split('.').reduce((o, p) => o?.[p], obj);
}
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;
}
Step 5: 运行测试确认通过
运行:
bun test tests/render.test.ts
预期:PASS
Step 6: Commit
git add src/engine/escpos.ts src/engine/render.ts tests/render.test.ts
git commit -m "feat: add ESC/POS renderer"
Phase 2: 打印机驱动
Task 6: 打印机连接器
Files:
- Create:
src/printer/connector.ts - Create:
tests/connector.test.ts
Step 1: 编写测试
创建 tests/connector.test.ts:
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);
});
});
Step 2: 运行测试确认失败
运行:
bun test tests/connector.test.ts
预期:FAIL
Step 3: 实现连接器
创建 src/printer/connector.ts:
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 queue: 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.queue.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.queue.push(job);
this.processQueue();
return job.id;
}
getJob(jobId: string): PrintJob | undefined {
if (this.currentJob?.id === jobId) return this.currentJob;
return this.queue.find(j => j.id === jobId);
}
getAllJobs(): PrintJob[] {
return this.currentJob
? [this.currentJob, ...this.queue]
: [...this.queue];
}
cancelJob(jobId: string): boolean {
const index = this.queue.findIndex(j => j.id === jobId);
if (index >= 0 && this.queue[index].status === 'pending') {
this.queue[index].status = 'cancelled' as any;
this.queue.splice(index, 1);
return true;
}
return false;
}
async processQueue(): Promise<void> {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
const job = this.queue.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,
});
let timeout: Timer;
socket.then((conn: any) => {
this.connected = true;
conn.write(data);
conn.end();
timeout = 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' };
}
}
}
Step 4: 运行测试确认通过
运行:
bun test tests/connector.test.ts
预期:PASS
Step 5: Commit
git add src/printer/connector.ts tests/connector.test.ts
git commit -m "feat: add printer connector with job queue"
Phase 3: REST API 服务
Task 7: Hono HTTP 服务搭建
Files:
- Create:
src/server.ts - Create:
src/api/routes.ts
Step 1: 创建主服务器
创建 src/server.ts:
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { apiRoutes } from './api/routes';
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,
};
Step 2: 创建 API 路由
创建 src/api/routes.ts:
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.put('/templates/:id', async (c) => {
const id = c.req.param('id');
const existing = templates.get(id);
if (!existing) {
return c.json({
success: false,
error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${id}' not found` }
}, 404);
}
const body = await c.req.json();
const updated: Template = {
...existing,
...body.config,
id, // ID 不可变
};
templates.set(id, updated);
return c.json({ success: true, template: updated });
});
// 删除模板
api.delete('/templates/:id', (c) => {
const id = c.req.param('id');
if (!templates.has(id)) {
return c.json({
success: false,
error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${id}' not found` }
}, 404);
}
templates.delete(id);
return c.json({ success: true, message: 'Template deleted' });
});
// 获取模板 Schema
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 = {}, options = {} } = body;
try {
// 渲染 ESC/POS 数据
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('/jobs/:jobId', (c) => {
const jobId = c.req.param('jobId');
const printer = getPrinter();
const job = printer.getJob(jobId);
if (!job) {
return c.json({
success: false,
error: { code: 'JOB_NOT_FOUND', message: `Job '${jobId}' not found` }
}, 404);
}
return c.json({
success: true,
job: {
id: job.id,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
error: job.error
}
});
});
// 列出任务
api.get('/jobs', (c) => {
const printer = getPrinter();
const jobs = printer.getAllJobs().map(j => ({
id: j.id,
status: j.status,
createdAt: j.createdAt
}));
return c.json({ success: true, jobs });
});
// ========== 打印机管理 ==========
// 获取打印机状态
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();
// 简单的测试页 ESC/POS 数据
const testData = new Uint8Array([
0x1B, 0x40, // 初始化
0x1B, 0x61, 0x01, // 居中
...new TextEncoder().encode('Printer Test Page\n'),
...new TextEncoder().encode('================\n'),
...new TextEncoder().encode('If you can read this,\n'),
...new TextEncoder().encode('your printer is working!\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 };
Step 3: 测试服务器启动
运行:
bun run src/server.ts
预期:输出 🖨️ Receipt Printer Server starting on http://localhost:3000
按 Ctrl+C 停止
Step 4: Commit
git add src/server.ts src/api/routes.ts
git commit -m "feat: add Hono HTTP server with REST API"
Phase 4: Web 配置界面
Task 8: Web 界面基础
Files:
- Create:
src/static/index.html - Create:
src/static/app.js - Create:
src/static/style.css
Step 1: 创建 HTML 结构
创建 src/static/index.html:
<!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>
Step 2: 创建样式
创建 src/static/style.css:
* {
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;
}
Step 3: 创建 JavaScript 逻辑
创建 src/static/app.js:
// 全局状态
let currentTemplateId = null;
let editor = null;
let templates = [];
// 初始化
document.addEventListener('DOMContentLoaded', () => {
initEditor();
loadTemplates();
checkPrinterStatus();
bindEvents();
// 定期刷新打印机状态
setInterval(checkPrinterStatus, 5000);
});
// 初始化 CodeMirror 编辑器
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;
// 转换为 YAML
const yaml = jsyaml.dump(data.template.config || data.template);
editor.setValue(yaml);
renderTemplateList();
updatePreview();
loadSchema(id);
}
} 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);
}
}
// 渲染为 HTML(简化版)
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) : '';
const colStyle = [`text-${col.align || 'left'}`];
if (col.bold) colStyle.push('text-bold');
return `<span class="${colStyle.join(' ')}">${escapeHtml(content)}</span>`;
});
return `<div class="text-row" style="display: flex; justify-content: space-between;">${cols.join('')}</div>`;
}
case 'list': {
const items = getNestedValue(data, block.data.replace(/\{\{|\}\}/g, '').trim()) || [];
let listHtml = '';
for (const item of items) {
for (const subBlock of block.itemTemplate) {
listHtml += renderBlockToHTML(subBlock, { ...data, ...item });
}
}
return listHtml;
}
case 'space': {
const lines = block.lines || 1;
return '<br>'.repeat(lines);
}
default:
return '';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getNestedValue(obj, path) {
return path.split('.').reduce((o, p) => o?.[p], obj);
}
// 获取测试数据
function getTestData() {
try {
const json = document.getElementById('dataEditor').value;
return json ? JSON.parse(json) : {};
} catch {
return {};
}
}
// 加载 Schema
async function loadSchema(id) {
try {
const res = await fetch(`/api/templates/${id}/schema`);
const data = await res.json();
if (data.success) {
document.getElementById('schemaDisplay').textContent =
JSON.stringify(data.schema, null, 2);
// 设置默认测试数据
if (!document.getElementById('dataEditor').value) {
document.getElementById('dataEditor').value =
JSON.stringify(data.example, null, 2);
}
}
} catch (err) {
console.error('Failed to load schema:', err);
}
}
// 检查打印机状态
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', saveTemplate);
// 删除模板
document.getElementById('deleteTemplate').addEventListener('click', deleteTemplate);
// Tab 切换
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');
});
});
// 测试数据变化时更新预览
document.getElementById('dataEditor').addEventListener('input', debounce(updatePreview, 300));
// 打印测试
document.getElementById('testPrint').addEventListener('click', testPrint);
// 插入块
document.querySelectorAll('[data-block]').forEach(btn => {
btn.addEventListener('click', (e) => {
const type = e.target.dataset.block;
insertBlock(type);
});
});
}
// 保存模板
async function saveTemplate() {
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);
}
}
// 删除模板
async function deleteTemplate() {
if (!currentTemplateId) return;
if (!confirm('确定要删除这个模板吗?')) return;
try {
const res = await fetch(`/api/templates/${currentTemplateId}`, {
method: 'DELETE'
});
const data = await res.json();
if (data.success) {
currentTemplateId = null;
document.getElementById('templateName').value = '';
editor.setValue('');
loadTemplates();
}
} catch (err) {
console.error('Failed to delete template:', err);
}
}
// 测试打印
async function testPrint() {
if (!currentTemplateId) {
alert('请先选择或保存模板');
return;
}
try {
const testData = getTestData();
const res = await fetch(`/api/print/${currentTemplateId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: testData })
});
const data = await res.json();
if (data.success) {
alert('打印任务已添加: ' + data.jobId);
} else {
alert('打印失败: ' + data.error.message);
}
} catch (err) {
alert('打印失败: ' + err.message);
}
}
// 插入块模板
function insertBlock(type) {
const templates = {
text: ` - type: text
content: "文本内容"
align: left
fontSize: normal
bold: false`,
row: ` - type: row
columns:
- content: "左侧"
align: left
width: 50%
- content: "右侧"
align: right
width: 50%`,
table: ` - type: table
columns:
- header: "列1"
align: left
width: 50%
- header: "列2"
align: right
width: 50%
data: "{{items}}"`,
list: ` - type: list
data: "{{items}}"
itemTemplate:
- type: text
content: "{{name}}"`,
divider: ` - type: divider
char: "="`,
barcode: ` - type: barcode
format: "CODE128"
data: "{{code}}"
align: center
height: 64`,
space: ` - type: space
lines: 1`
};
const doc = editor.getDoc();
const cursor = doc.getCursor();
doc.replaceRange('\n' + templates[type], cursor);
}
Step 4: 测试 Web 界面
运行服务器:
bun run src/server.ts
打开浏览器访问 http://localhost:3000
预期看到:
- 左侧模板列表
- 中间 YAML 编辑器
- 右侧预览面板
- 顶部打印机状态
Step 5: Commit
git add src/static/
git commit -m "feat: add web configuration interface"
Phase 5: 集成与部署
Task 9: 加载示例模板
Files:
- Modify:
src/server.ts - Create:
src/config/loader.ts
Step 1: 创建配置加载器
创建 src/config/loader.ts:
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;
}
Step 2: 修改服务器加载示例
修改 src/server.ts 文件顶部,添加示例加载:
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { apiRoutes } from './api/routes';
import { loadExampleTemplates } from './config/loader';
const app = new Hono();
// 加载示例模板
const templates = await loadExampleTemplates();
// 将示例模板传递给 API 路由(需要修改 routes.ts 接受外部 templates)
// ...
Step 3: 修改 API 路由接受外部存储
修改 src/api/routes.ts,将内存存储改为接受外部参数(略,需调整)
Step 4: Commit
git add src/config/loader.ts src/server.ts src/api/routes.ts
git commit -m "feat: load example templates on startup"
Task 10: 启动脚本和 README
Files:
- Modify:
package.json - Modify:
README.md
Step 1: 更新 package.json 脚本
修改 package.json:
{
"scripts": {
"start": "bun run src/server.ts",
"dev": "bun run --watch src/server.ts",
"build": "bun build src/server.ts --outdir dist --target bun",
"test": "bun test"
}
}
Step 2: 更新 README.md
修改 README.md 添加使用说明(略)
Step 3: 最终测试
运行:
bun install
bun run start
访问 http://localhost:3000,验证:
- 示例模板已加载
- 可以编辑和预览
- 可以保存新模板
- 可以打印测试页
Step 4: 最终 Commit
git add package.json README.md
git commit -m "chore: add startup scripts and documentation"
测试清单
单元测试
bun test所有测试通过
集成测试
- 启动服务器无错误
- 加载示例模板成功
- 创建/保存/删除模板正常
- 实时预览正常
- Schema 自动生成正确
- 打印任务队列工作正常
界面测试
- 编辑器语法高亮正常
- Tab 切换正常
- 插入块代码正确
- 预览样式正确
执行命令总结
# 开发启动
bun run dev
# 生产启动
bun run start
# 运行测试
bun test
# 构建
bun run build
Plan complete and saved to docs/plans/2025-02-12-receipt-printer-implementation.md.
Two execution options:
1. Subagent-Driven (this session) - I dispatch fresh subagent per task, review between tasks, fast iteration
2. Parallel Session (separate) - Open new session with executing-plans, batch execution with checkpoints
Which approach?