diff --git a/docs/plans/2025-02-12-receipt-printer-implementation.md b/docs/plans/2025-02-12-receipt-printer-implementation.md new file mode 100644 index 0000000..04ab09d --- /dev/null +++ b/docs/plans/2025-02-12-receipt-printer-implementation.md @@ -0,0 +1,2570 @@ +# 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? \ No newline at end of file