# 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: 核心模板引擎](#phase-1-核心模板引擎) - [Phase 2: 打印机驱动](#phase-2-打印机驱动) - [Phase 3: REST API 服务](#phase-3-rest-api-服务) - [Phase 4: Web 配置界面](#phase-4-web-配置界面) - [Phase 5: 集成与部署](#phase-5-集成与部署) --- ## Phase 1: 核心模板引擎 ### Task 1: 项目初始化 **Files:** - Create: `package.json` - Create: `bun.lock` - Create: `tsconfig.json` - Create: `.gitignore` **Step 1: 初始化 Bun 项目** 运行: ```bash cd receipt-printer bun init -y ``` **Step 2: 安装依赖** 运行: ```bash bun add hono js-yaml mustache bun add -d @types/js-yaml @types/mustache bun-types ``` **Step 3: 创建 tsconfig.json** 创建 `tsconfig.json`: ```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** ```bash 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`: ```typescript // 对齐方式 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** ```bash 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`: ```typescript 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: 运行测试确认失败** 运行: ```bash bun test tests/parser.test.ts ``` 预期:FAIL - "parseTemplate is not defined" **Step 3: 实现解析器** 创建 `src/engine/parser.ts`: ```typescript 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: 运行测试确认通过** 运行: ```bash bun test tests/parser.test.ts ``` 预期:PASS - 4/4 tests passed **Step 5: Commit** ```bash 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`: ```typescript 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: 运行测试确认失败** 运行: ```bash bun test tests/schema.test.ts ``` 预期:FAIL **Step 3: 实现 Schema 提取器** 创建 `src/engine/schema.ts`: ```typescript import type { Template, Block } from '../types/template'; interface JSONSchema { type: string; properties: Record; required?: string[]; } export function extractSchema(template: Template): { schema: JSONSchema; example: any } { const variables = new Set(); const arrayVariables = new Set(); 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 = {}; 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: 运行测试确认通过** 运行: ```bash bun test tests/schema.test.ts ``` 预期:PASS **Step 5: Commit** ```bash 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`: ```typescript // 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`: ```typescript 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: 运行测试确认失败** 运行: ```bash bun test tests/render.test.ts ``` 预期:FAIL **Step 4: 实现渲染器** 创建 `src/engine/render.ts`: ```typescript 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: 运行测试确认通过** 运行: ```bash bun test tests/render.test.ts ``` 预期:PASS **Step 6: Commit** ```bash 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`: ```typescript 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: 运行测试确认失败** 运行: ```bash bun test tests/connector.test.ts ``` 预期:FAIL **Step 3: 实现连接器** 创建 `src/printer/connector.ts`: ```typescript 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 { 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 { 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: 运行测试确认通过** 运行: ```bash bun test tests/connector.test.ts ``` 预期:PASS **Step 5: Commit** ```bash 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`: ```typescript 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`: ```typescript 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(); // 打印机连接器(单例) 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: 测试服务器启动** 运行: ```bash bun run src/server.ts ``` 预期:输出 `🖨️ Receipt Printer Server starting on http://localhost:3000` 按 Ctrl+C 停止 **Step 4: Commit** ```bash 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`: ```html Receipt Printer - 小票打印机配置

🖨️ Receipt Printer

离线
插入:
``` **Step 2: 创建样式** 创建 `src/static/style.css`: ```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`: ```javascript // 全局状态 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 '

配置无效

'; } 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 `
${escapeHtml(content)}
`; } case 'divider': { const char = block.char || '-'; return `
${char.repeat(48)}
`; } 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 `${escapeHtml(content)}`; }); return `
${cols.join('')}
`; } 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 '
'.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 界面** 运行服务器: ```bash bun run src/server.ts ``` 打开浏览器访问 `http://localhost:3000` 预期看到: - 左侧模板列表 - 中间 YAML 编辑器 - 右侧预览面板 - 顶部打印机状态 **Step 5: Commit** ```bash 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`: ```typescript import { parseTemplate } from '../engine/parser'; import type { Template } from '../types/template'; export async function loadExampleTemplates(): Promise> { const templates = new Map(); 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` 文件顶部,添加示例加载: ```typescript 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** ```bash 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`: ```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: 最终测试** 运行: ```bash bun install bun run start ``` 访问 `http://localhost:3000`,验证: - 示例模板已加载 - 可以编辑和预览 - 可以保存新模板 - 可以打印测试页 **Step 4: 最终 Commit** ```bash git add package.json README.md git commit -m "chore: add startup scripts and documentation" ``` --- ## 测试清单 ### 单元测试 - [ ] `bun test` 所有测试通过 ### 集成测试 - [ ] 启动服务器无错误 - [ ] 加载示例模板成功 - [ ] 创建/保存/删除模板正常 - [ ] 实时预览正常 - [ ] Schema 自动生成正确 - [ ] 打印任务队列工作正常 ### 界面测试 - [ ] 编辑器语法高亮正常 - [ ] Tab 切换正常 - [ ] 插入块代码正确 - [ ] 预览样式正确 --- ## 执行命令总结 ```bash # 开发启动 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?