receipt-printer/docs/plans/2025-02-12-receipt-printer-implementation.md
2026-02-12 07:46:20 +00:00

58 KiB
Raw Blame History

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="{&quot;key&quot;: &quot;value&quot;}"></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?