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

2570 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string, any>;
required?: string[];
}
export function extractSchema(template: Template): { schema: JSONSchema; example: any } {
const variables = new Set<string>();
const arrayVariables = new Set<string>();
function extractFromString(str: string) {
const matches = str.match(/\{\{([^}]+)\}\}/g);
if (matches) {
matches.forEach(match => {
const varName = match.replace(/\{\{|\}\}/g, '').trim().split('.')[0];
variables.add(varName);
});
}
}
function extractFromBlock(block: Block) {
switch (block.type) {
case 'text':
extractFromString(block.content);
break;
case 'row':
block.columns.forEach(col => {
if (col.content) extractFromString(col.content);
if (col.header) extractFromString(col.header);
});
break;
case 'table':
extractFromString(block.data);
block.columns.forEach(col => {
if (col.header) extractFromString(col.header);
});
break;
case 'list':
extractFromString(block.data);
block.itemTemplate.forEach(extractFromBlock);
break;
case 'divider':
break;
case 'image':
extractFromString(block.src);
break;
case 'barcode':
extractFromString(block.data);
break;
case 'space':
break;
}
}
template.blocks.forEach(extractFromBlock);
// 构建 JSON Schema
const properties: Record<string, any> = {};
variables.forEach(varName => {
if (varName === 'items' || varName === 'tasks' || varName === 'tickets') {
properties[varName] = { type: 'array', description: `${varName} list` };
arrayVariables.add(varName);
} else if (varName.endsWith('Count') || varName === 'quantity' || varName === 'total') {
properties[varName] = { type: 'number', description: varName };
} else {
properties[varName] = { type: 'string', description: varName };
}
});
const schema: JSONSchema = {
type: 'object',
properties
};
// 生成示例数据
const example: any = {};
variables.forEach(varName => {
if (arrayVariables.has(varName)) {
example[varName] = [
{ name: 'Item 1', value: 'value1' },
{ name: 'Item 2', value: 'value2' }
];
} else if (properties[varName].type === 'number') {
example[varName] = 42;
} else if (varName.includes('date') || varName.includes('Date')) {
example[varName] = '2025-02-12';
} else if (varName.includes('time') || varName.includes('Time')) {
example[varName] = '14:30:00';
} else {
example[varName] = varName;
}
});
return { schema, example };
}
```
**Step 4: 运行测试确认通过**
运行:
```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<void> {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
const job = this.queue.shift()!;
this.currentJob = job;
job.status = 'printing';
job.startedAt = new Date();
try {
await this.sendToPrinter(job.data);
job.status = 'completed';
job.completedAt = new Date();
} catch (error) {
job.status = 'failed';
job.error = String(error);
console.error(`Print job ${job.id} failed:`, error);
}
}
this.currentJob = null;
this.processing = false;
}
private async sendToPrinter(data: Uint8Array): Promise<void> {
return new Promise((resolve, reject) => {
const socket = new (Bun as any).connect({
hostname: this.config.ip,
port: this.config.port,
});
let timeout: Timer;
socket.then((conn: any) => {
this.connected = true;
conn.write(data);
conn.end();
timeout = setTimeout(() => {
this.connected = false;
resolve();
}, 1000);
}).catch((err: any) => {
this.connected = false;
reject(err);
});
});
}
async getStatus(): Promise<{ online: boolean; paperStatus: string }> {
// 简化实现,实际应该发送状态查询指令
try {
const socket = await (Bun as any).connect({
hostname: this.config.ip,
port: this.config.port,
});
socket.end();
return { online: true, paperStatus: 'ok' };
} catch {
return { online: false, paperStatus: 'unknown' };
}
}
}
```
**Step 4: 运行测试确认通过**
运行:
```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<string, Template>();
// 打印机连接器(单例)
let printerConnector: PrinterConnector | null = null;
function getPrinter(): PrinterConnector {
if (!printerConnector) {
const ip = process.env.PRINTER_IP || '192.168.1.100';
const port = parseInt(process.env.PRINTER_PORT || '9100');
printerConnector = new PrinterConnector({ ip, port });
}
return printerConnector;
}
const api = new Hono();
// ========== 模板管理 ==========
// 列出所有模板
api.get('/templates', (c) => {
const list = Array.from(templates.values()).map(t => ({
id: t.id,
name: t.name,
description: t.description,
}));
return c.json({ success: true, templates: list });
});
// 获取单个模板
api.get('/templates/:id', (c) => {
const id = c.req.param('id');
const template = templates.get(id);
if (!template) {
return c.json({
success: false,
error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${id}' not found` }
}, 404);
}
return c.json({ success: true, template });
});
// 创建模板
api.post('/templates', async (c) => {
const body = await c.req.json();
if (!body.id || !body.config) {
return c.json({
success: false,
error: { code: 'INVALID_REQUEST', message: 'Missing id or config' }
}, 400);
}
try {
// 验证配置
const config = typeof body.config === 'string'
? parseTemplate(body.config)
: body.config;
const template: Template = {
...config,
id: body.id,
};
templates.set(body.id, template);
return c.json({
success: true,
template: { id: template.id, name: template.name }
}, 201);
} catch (error) {
return c.json({
success: false,
error: { code: 'INVALID_TEMPLATE', message: String(error) }
}, 400);
}
});
// 更新模板
api.put('/templates/:id', async (c) => {
const id = c.req.param('id');
const existing = templates.get(id);
if (!existing) {
return c.json({
success: false,
error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${id}' not found` }
}, 404);
}
const body = await c.req.json();
const updated: Template = {
...existing,
...body.config,
id, // ID 不可变
};
templates.set(id, updated);
return c.json({ success: true, template: updated });
});
// 删除模板
api.delete('/templates/:id', (c) => {
const id = c.req.param('id');
if (!templates.has(id)) {
return c.json({
success: false,
error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${id}' not found` }
}, 404);
}
templates.delete(id);
return c.json({ success: true, message: 'Template deleted' });
});
// 获取模板 Schema
api.get('/templates/:id/schema', (c) => {
const id = c.req.param('id');
const template = templates.get(id);
if (!template) {
return c.json({
success: false,
error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${id}' not found` }
}, 404);
}
const { schema, example } = extractSchema(template);
return c.json({
success: true,
templateId: id,
schema,
example
});
});
// ========== 打印 ==========
// 执行打印
api.post('/print/:templateId', async (c) => {
const templateId = c.req.param('templateId');
const template = templates.get(templateId);
if (!template) {
return c.json({
success: false,
error: { code: 'TEMPLATE_NOT_FOUND', message: `Template '${templateId}' not found` }
}, 404);
}
const body = await c.req.json();
const { data = {}, options = {} } = body;
try {
// 渲染 ESC/POS 数据
const escData = renderTemplate(template, data);
// 发送给打印机
const printer = getPrinter();
const jobId = printer.queue(escData);
return c.json({
success: true,
jobId,
status: 'queued',
estimatedTime: '5s'
});
} catch (error) {
return c.json({
success: false,
error: { code: 'RENDER_ERROR', message: String(error) }
}, 500);
}
});
// ========== 打印任务 ==========
// 获取任务状态
api.get('/jobs/:jobId', (c) => {
const jobId = c.req.param('jobId');
const printer = getPrinter();
const job = printer.getJob(jobId);
if (!job) {
return c.json({
success: false,
error: { code: 'JOB_NOT_FOUND', message: `Job '${jobId}' not found` }
}, 404);
}
return c.json({
success: true,
job: {
id: job.id,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
error: job.error
}
});
});
// 列出任务
api.get('/jobs', (c) => {
const printer = getPrinter();
const jobs = printer.getAllJobs().map(j => ({
id: j.id,
status: j.status,
createdAt: j.createdAt
}));
return c.json({ success: true, jobs });
});
// ========== 打印机管理 ==========
// 获取打印机状态
api.get('/printer/status', async (c) => {
const printer = getPrinter();
const status = await printer.getStatus();
return c.json({
success: true,
printer: {
ip: process.env.PRINTER_IP || '192.168.1.100',
port: parseInt(process.env.PRINTER_PORT || '9100'),
...status,
queueLength: printer.getQueueLength()
}
});
});
// 打印测试页
api.post('/printer/test', (c) => {
const printer = getPrinter();
// 简单的测试页 ESC/POS 数据
const testData = new Uint8Array([
0x1B, 0x40, // 初始化
0x1B, 0x61, 0x01, // 居中
...new TextEncoder().encode('Printer Test Page\n'),
...new TextEncoder().encode('================\n'),
...new TextEncoder().encode('If you can read this,\n'),
...new TextEncoder().encode('your printer is working!\n'),
0x1B, 0x61, 0x00, // 左对齐
0x0A, 0x0A, // 空行
0x1D, 0x56, 0x00, // 切纸
]);
const jobId = printer.queue(testData);
return c.json({
success: true,
jobId,
message: 'Test page queued'
});
});
export { api as apiRoutes };
```
**Step 3: 测试服务器启动**
运行:
```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
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt Printer - 小票打印机配置</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
</head>
<body>
<div class="app">
<header class="header">
<h1>🖨️ Receipt Printer</h1>
<div class="printer-status" id="printerStatus">
<span class="status-dot offline"></span>
<span class="status-text">离线</span>
</div>
</header>
<div class="main">
<!-- 左侧:模板列表 -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>模板</h2>
<button class="btn btn-primary" id="newTemplate">+ 新建</button>
</div>
<ul class="template-list" id="templateList">
<!-- 动态加载 -->
</ul>
</aside>
<!-- 中间:编辑器 -->
<div class="editor">
<div class="editor-header">
<input type="text" id="templateName" placeholder="模板名称" class="template-name">
<div class="editor-actions">
<button class="btn" id="saveTemplate">保存</button>
<button class="btn btn-danger" id="deleteTemplate">删除</button>
</div>
</div>
<div class="editor-body">
<textarea id="yamlEditor"></textarea>
</div>
<div class="editor-toolbar">
<span>插入:</span>
<button class="btn btn-sm" data-block="text">文本</button>
<button class="btn btn-sm" data-block="row">多列</button>
<button class="btn btn-sm" data-block="table">表格</button>
<button class="btn btn-sm" data-block="list">列表</button>
<button class="btn btn-sm" data-block="divider">分隔线</button>
<button class="btn btn-sm" data-block="barcode">条码</button>
<button class="btn btn-sm" data-block="space">空行</button>
</div>
</div>
<!-- 右侧:预览和数据 -->
<aside class="preview-panel">
<div class="preview-tabs">
<button class="tab-btn active" data-tab="preview">预览</button>
<button class="tab-btn" data-tab="data">测试数据</button>
<button class="tab-btn" data-tab="schema">Schema</button>
</div>
<div class="tab-content" id="previewTab">
<div class="receipt-preview" id="receiptPreview">
<!-- 实时预览 -->
</div>
</div>
<div class="tab-content hidden" id="dataTab">
<textarea id="dataEditor" placeholder="{&quot;key&quot;: &quot;value&quot;}"></textarea>
<button class="btn btn-primary" id="testPrint">打印测试</button>
</div>
<div class="tab-content hidden" id="schemaTab">
<pre id="schemaDisplay"></pre>
</div>
</aside>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/4.2.0/mustache.min.js"></script>
<script src="app.js"></script>
</body>
</html>
```
**Step 2: 创建样式**
创建 `src/static/style.css`:
```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 '<p style="color: #999;">配置无效</p>';
}
let html = '';
for (const block of config.blocks) {
html += renderBlockToHTML(block, data);
}
return html;
}
function renderBlockToHTML(block, data) {
const style = [];
if (block.bold) style.push('text-bold');
if (block.fontSize === 'small') style.push('text-small');
if (block.fontSize === 'large') style.push('text-large');
if (block.fontSize === 'xlarge') style.push('text-xlarge');
const align = block.align || 'left';
style.push(`text-${align}`);
switch (block.type) {
case 'text': {
const content = Mustache.render(block.content, data);
return `<div class="${style.join(' ')}">${escapeHtml(content)}</div>`;
}
case 'divider': {
const char = block.char || '-';
return `<div class="divider">${char.repeat(48)}</div>`;
}
case 'row': {
const cols = block.columns.map(col => {
const content = col.content ? Mustache.render(col.content, data) : '';
const colStyle = [`text-${col.align || 'left'}`];
if (col.bold) colStyle.push('text-bold');
return `<span class="${colStyle.join(' ')}">${escapeHtml(content)}</span>`;
});
return `<div class="text-row" style="display: flex; justify-content: space-between;">${cols.join('')}</div>`;
}
case 'list': {
const items = getNestedValue(data, block.data.replace(/\{\{|\}\}/g, '').trim()) || [];
let listHtml = '';
for (const item of items) {
for (const subBlock of block.itemTemplate) {
listHtml += renderBlockToHTML(subBlock, { ...data, ...item });
}
}
return listHtml;
}
case 'space': {
const lines = block.lines || 1;
return '<br>'.repeat(lines);
}
default:
return '';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getNestedValue(obj, path) {
return path.split('.').reduce((o, p) => o?.[p], obj);
}
// 获取测试数据
function getTestData() {
try {
const json = document.getElementById('dataEditor').value;
return json ? JSON.parse(json) : {};
} catch {
return {};
}
}
// 加载 Schema
async function loadSchema(id) {
try {
const res = await fetch(`/api/templates/${id}/schema`);
const data = await res.json();
if (data.success) {
document.getElementById('schemaDisplay').textContent =
JSON.stringify(data.schema, null, 2);
// 设置默认测试数据
if (!document.getElementById('dataEditor').value) {
document.getElementById('dataEditor').value =
JSON.stringify(data.example, null, 2);
}
}
} catch (err) {
console.error('Failed to load schema:', err);
}
}
// 检查打印机状态
async function checkPrinterStatus() {
try {
const res = await fetch('/api/printer/status');
const data = await res.json();
const statusEl = document.getElementById('printerStatus');
const dot = statusEl.querySelector('.status-dot');
const text = statusEl.querySelector('.status-text');
if (data.success && data.printer.online) {
dot.className = 'status-dot online';
text.textContent = '在线';
} else {
dot.className = 'status-dot offline';
text.textContent = '离线';
}
} catch {
const dot = document.querySelector('.status-dot');
const text = document.querySelector('.status-text');
if (dot) dot.className = 'status-dot offline';
if (text) text.textContent = '离线';
}
}
// 绑定事件
function bindEvents() {
// 新建模板
document.getElementById('newTemplate').addEventListener('click', () => {
currentTemplateId = null;
document.getElementById('templateName').value = '';
editor.setValue(`name: "新模板"
id: "new-template"
width: 80mm
blocks:
- type: text
content: "Hello World"
align: center
`);
document.getElementById('receiptPreview').innerHTML = '';
renderTemplateList();
});
// 保存模板
document.getElementById('saveTemplate').addEventListener('click', saveTemplate);
// 删除模板
document.getElementById('deleteTemplate').addEventListener('click', deleteTemplate);
// Tab 切换
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
document.getElementById(tab + 'Tab').classList.remove('hidden');
});
});
// 测试数据变化时更新预览
document.getElementById('dataEditor').addEventListener('input', debounce(updatePreview, 300));
// 打印测试
document.getElementById('testPrint').addEventListener('click', testPrint);
// 插入块
document.querySelectorAll('[data-block]').forEach(btn => {
btn.addEventListener('click', (e) => {
const type = e.target.dataset.block;
insertBlock(type);
});
});
}
// 保存模板
async function saveTemplate() {
if (!currentTemplateId) {
// 新建模板
const id = prompt('输入模板 ID英文、数字、连字符:');
if (!id) return;
currentTemplateId = id;
}
try {
const yaml = editor.getValue();
const config = jsyaml.load(yaml);
const name = document.getElementById('templateName').value || config.name || '未命名';
const res = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: currentTemplateId,
name,
config
})
});
const data = await res.json();
if (data.success) {
alert('保存成功');
loadTemplates();
} else {
alert('保存失败: ' + data.error.message);
}
} catch (err) {
alert('保存失败: ' + err.message);
}
}
// 删除模板
async function deleteTemplate() {
if (!currentTemplateId) return;
if (!confirm('确定要删除这个模板吗?')) return;
try {
const res = await fetch(`/api/templates/${currentTemplateId}`, {
method: 'DELETE'
});
const data = await res.json();
if (data.success) {
currentTemplateId = null;
document.getElementById('templateName').value = '';
editor.setValue('');
loadTemplates();
}
} catch (err) {
console.error('Failed to delete template:', err);
}
}
// 测试打印
async function testPrint() {
if (!currentTemplateId) {
alert('请先选择或保存模板');
return;
}
try {
const testData = getTestData();
const res = await fetch(`/api/print/${currentTemplateId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: testData })
});
const data = await res.json();
if (data.success) {
alert('打印任务已添加: ' + data.jobId);
} else {
alert('打印失败: ' + data.error.message);
}
} catch (err) {
alert('打印失败: ' + err.message);
}
}
// 插入块模板
function insertBlock(type) {
const templates = {
text: ` - type: text
content: "文本内容"
align: left
fontSize: normal
bold: false`,
row: ` - type: row
columns:
- content: "左侧"
align: left
width: 50%
- content: "右侧"
align: right
width: 50%`,
table: ` - type: table
columns:
- header: "列1"
align: left
width: 50%
- header: "列2"
align: right
width: 50%
data: "{{items}}"`,
list: ` - type: list
data: "{{items}}"
itemTemplate:
- type: text
content: "{{name}}"`,
divider: ` - type: divider
char: "="`,
barcode: ` - type: barcode
format: "CODE128"
data: "{{code}}"
align: center
height: 64`,
space: ` - type: space
lines: 1`
};
const doc = editor.getDoc();
const cursor = doc.getCursor();
doc.replaceRange('\n' + templates[type], cursor);
}
```
**Step 4: 测试 Web 界面**
运行服务器:
```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<Map<string, Template>> {
const templates = new Map<string, Template>();
const examples = [
'daily-todo.yaml',
'food-order-simple.yaml',
'fancy-receipt.yaml',
'ticket-list.yaml',
'long-text.yaml'
];
for (const filename of examples) {
try {
const file = Bun.file(`./templates/examples/${filename}`);
if (await file.exists()) {
const content = await file.text();
const template = parseTemplate(content);
templates.set(template.id, template);
console.log(`✅ Loaded example template: ${template.name}`);
}
} catch (err) {
console.error(`❌ Failed to load ${filename}:`, err);
}
}
return templates;
}
```
**Step 2: 修改服务器加载示例**
修改 `src/server.ts` 文件顶部,添加示例加载:
```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?