2570 lines
58 KiB
Markdown
2570 lines
58 KiB
Markdown
# 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="{"key": "value"}"></textarea>
|
||
<button class="btn btn-primary" id="testPrint">打印测试</button>
|
||
</div>
|
||
|
||
<div class="tab-content hidden" id="schemaTab">
|
||
<pre id="schemaDisplay"></pre>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/4.2.0/mustache.min.js"></script>
|
||
<script src="app.js"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
**Step 2: 创建样式**
|
||
|
||
创建 `src/static/style.css`:
|
||
```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? |