Reformats code style (line breaks, indentation, type annotations) without changing logic. Also includes minor feature additions bundled in the same lint run (stats service, user-settings groups, agrarmonitor polling improvements). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
38 KiB
Label-Print-Agent – Architektur & Implementierungsreferenz
Dieses Dokument beschreibt vollständig das Label-Print-Agent-System aus dem Paperless Manager. Es dient als Grundlage für die Reimplementierung in einem anderen Projekt.
1. Systemübersicht
Das System ermöglicht es, Etiketten-Templates zu definieren (mit Layout, Variablen-Feldern und Abmessungen) und daraus Druckjobs zu erzeugen, die von einem oder mehreren Print Agents abgeholt und gedruckt werden.
Beteiligte Komponenten
| Komponente | Aufgabe |
|---|---|
| Frontend | Template-Verwaltung, Job-Erstellung, Label-Vorschau |
| Backend API | REST-Endpunkte, Job-Queue, Rendering |
| Datenbank | Persistenz für Templates und Jobs |
| LabelRenderer | SVG → PNG (300 DPI) |
| Print Agent | Eigenständiger Client, der Jobs abholt und druckt |
Datenfluss (ASCII)
┌────────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ • Template anlegen (Name, Layout, Felder, Maße) │
│ • Vorschau rendern (POST /preview → PNG) │
│ • Druckjob erstellen (POST /jobs → { jobId }) │
└──────────────────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ BACKEND: createJob() │
│ 1. Template laden │
│ 2. Variablen aufbauen (Felder + Datumssubfelder + {number}) │
│ 3. LabelGetUrl aufrufen → {number} reservieren (optional) │
│ 4. Job speichern (Status=pending, imageData=null) │
│ 5. newJob$.next() → SSE-Notification an alle Agents │
└──────────────────────────────┬─────────────────────────────────────┘
│
┌───────────────┴──────────────────┐
│ SSE-Push │ Polling (Fallback)
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ PRINT AGENT │
│ GET /jobs/next?agentId=MEIN-PC │
│ → Lock setzen (lockedAt = now) │
│ → Lazy Render: Layout → SVG → PNG → in DB speichern │
│ → { jobId, labelImageBase64, widthMm, heightMm } │
│ │
│ Drucken → POST /jobs/:id/printed (Erfolg) │
│ → POST /jobs/:id/error (Fehler) │
└──────────────────────────────────────────────────────────────────┘
2. Datenmodelle
2.1 LabelTemplate (in PM: Teil des BarcodeTemplate)
Für das neue Projekt empfiehlt sich eine eigenständige LabelTemplate-Entity:
export interface LabelInputField {
name: string; // Technischer Feldname (Variablenname)
label: string; // Anzeigetext im Formular
type: 'text' | 'number' | 'date';
}
export type LabelElement =
| {
type: 'text';
content: string; // Inhalt, kann {variablenname} enthalten
x: number; // X-Position in mm ab linker Kante
y: number; // Y-Position in mm ab oberer Kante
fontSize: number; // Schriftgröße in mm
bold?: boolean;
align?: 'left' | 'center' | 'right';
maxWidth?: number; // Maximale Breite in mm (Text wird gestreckt/gestaucht)
}
| {
type: 'qr';
content: string; // QR-Inhalt, kann {variablenname} enthalten
x: number; // X-Position in mm
y: number; // Y-Position in mm
sizeMm: number; // Quadratische Größe in mm
}
| {
type: 'line';
x1: number; y1: number; // Startpunkt in mm
x2: number; y2: number; // Endpunkt in mm
lineWidth?: number; // Linienbreite in mm (default: ~0,085mm = 1px bei 300DPI)
};
// TypeORM Entity (vereinfacht)
@Entity('label_templates')
export class LabelTemplate {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ type: 'int', default: 57 })
widthMm: number; // Etikettenbreite in mm
@Column({ type: 'int', default: 32 })
heightMm: number; // Etikettenhöhe in mm
@Column({ type: 'simple-json', nullable: true })
inputFields: LabelInputField[] | null; // Formularfelder
@Column({ type: 'simple-json', nullable: true })
layout: LabelElement[] | null; // Etikett-Elemente
@Column({ length: 1000, nullable: true })
getUrl: string | null; // URL zur Nummernreservierung (GET, {var} möglich)
@Column({ length: 1000, nullable: true })
printedUrl: string | null; // Callback nach erfolgreichem Druck (POST)
@Column({ length: 1000, nullable: true })
releaseUrl: string | null; // Callback bei Druckfehler (POST)
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
2.2 LabelPrintJob
Die DB-Tabelle fungiert als persistente Job-Queue.
// Originaldatei: paperless-backend/src/database/entities/label-print-job.entity.ts
@Entity('label_print_jobs')
export class LabelPrintJob {
@PrimaryGeneratedColumn()
Id: number;
@Index()
@Column({ type: 'varchar', length: 20, default: 'pending' })
Status: 'pending' | 'printed' | 'error';
@Column({ type: 'mediumblob', nullable: true })
LabelImageData: Buffer | null; // Lazy-rendered PNG (kann null sein bis Claim)
@Column({ type: 'int' })
LabelWidthMm: number;
@Column({ type: 'int' })
LabelHeightMm: number;
@Column({ type: 'int', nullable: true })
BarcodeTemplateId: number | null; // Referenz auf Template (ohne FK-Constraint)
@Column({ type: 'simple-json', nullable: true })
LabelVariables: Record<string, string> | null; // Aufgelöste Variablen zum Druckzeitpunkt
@Index()
@Column({ type: 'datetime', nullable: true })
LockedAt: Date | null; // Zeitstempel des Locks
@Column({ length: 255, nullable: true })
LockedByAgent: string | null; // Agenten-ID (z.B. "BUERO-PC")
@Column({ type: 'datetime', nullable: true })
PrintedAt: Date | null;
@Column({ length: 255, nullable: true })
PrintedByAgent: string | null;
@Column({ length: 255, nullable: true })
PrinterName: string | null;
@Column({ type: 'text', nullable: true })
ErrorMessage: string | null;
@CreateDateColumn()
CreatedAt: Date;
}
Wichtig: LabelVariables speichert die bereits aufgelösten Variablen zum Zeitpunkt der Job-Erstellung. Das Template-Layout kann sich danach ändern — der Job verwendet trotzdem die ursprünglichen Werte.
3. API-Endpunkte
Alle Endpunkte unter /api/label-print-agent:
Frontend-Endpunkte (erfordern Authentifizierung)
POST /preview — Label-Vorschau rendern
Rendert ein PNG ohne DB-Eintrag. Zum Testen von Layouts.
Request: { templateId: number, fieldValues?: Record<string, string> }
Response: image/png (Buffer)
POST /jobs — Druckjob erstellen
Request: { templateId: number, fieldValues?: Record<string, string> }
Response: { jobId: string }
Status: 201 Created
Agent-Endpunkte
GET /events — SSE-Stream (Server-Sent Events)
Agent hält diese Verbindung dauerhaft offen. Bei neuem Job erhält er eine Notification.
Response: text/event-stream
Payload: { type: 'label-job-available' }
Header: X-Accel-Buffering: no (wichtig für nginx-Reverse-Proxy)
GET /jobs/next?agentId=MEIN-PC — Nächsten Job beanspruchen
Response 200: {
jobId: string,
labelImageBase64: string | null, // PNG als Base64
labelImageContentType: 'image/png',
labelWidthMm: number,
labelHeightMm: number
}
Response 204: (kein Job verfügbar)
GET /jobs/:id/image — Job-Bild separat abrufen
Response: image/png (Buffer)
POST /jobs/:id/printed — Job als gedruckt markieren
Request: { agentId?: string, printerName?: string }
Response: { ok: true }
POST /jobs/:id/error — Druckfehler melden
Request: { agentId?: string, printerName?: string, errorMessage?: string }
Response: { ok: true }
4. SVG-Rendering-Pipeline
Originaldatei: paperless-backend/src/label-print-agent/label-renderer.service.ts
Abhängigkeiten
npm install qrcode @resvg/resvg-js
npm install --save-dev @types/qrcode
Vollständige Implementierung
import * as QRCode from 'qrcode';
import { Resvg } from '@resvg/resvg-js';
const MM_TO_PX = 300 / 25.4; // 300 DPI
function mm(v: number): number {
return Math.round(v * MM_TO_PX);
}
function escape(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// Variablen-Substitution: {varName} oder {varName:6} (zero-padded auf 6 Stellen)
function applyVars(template: string, vars: Record<string, string>): string {
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
const colonIdx = key.indexOf(':');
if (colonIdx !== -1) {
const varName = key.slice(0, colonIdx);
const width = parseInt(key.slice(colonIdx + 1), 10);
if (!isNaN(width)) {
return (vars[varName] ?? '').padStart(width, '0');
}
}
return vars[key] ?? `{${key}}`; // Unbekannte Variablen: Platzhalter stehen lassen
});
}
async function renderLabel(
layout: LabelElement[],
widthMm: number,
heightMm: number,
variables: Record<string, string>,
): Promise<Buffer> {
const W = mm(widthMm);
const H = mm(heightMm);
const parts: string[] = [];
for (const el of layout) {
if (el.type === 'text') {
const x = mm(el.x);
const fontSize = mm(el.fontSize);
const content = escape(applyVars(el.content, variables));
const fontWeight = el.bold ? 'bold' : 'normal';
const textAnchor =
el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start';
const maxWidthAttr = el.maxWidth
? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"`
: '';
// Wichtig: librsvg ignoriert dominant-baseline.
// Y-Koordinate ist die Oberkante des Texts → fontSize addieren für Baseline.
const yBaseline = mm(el.y) + fontSize;
parts.push(
`<text x="${x}" y="${yBaseline}" ` +
`font-family="Arial,Helvetica,sans-serif" font-size="${fontSize}" ` +
`font-weight="${fontWeight}" text-anchor="${textAnchor}"${maxWidthAttr}>${content}</text>`,
);
} else if (el.type === 'qr') {
const size = mm(el.sizeMm);
const content = applyVars(el.content, variables);
const qrBuffer = await QRCode.toBuffer(content, {
type: 'png',
margin: 0,
width: size,
errorCorrectionLevel: 'M',
});
const b64 = qrBuffer.toString('base64');
parts.push(
`<image href="data:image/png;base64,${b64}" ` +
`x="${mm(el.x)}" y="${mm(el.y)}" width="${size}" height="${size}"/>`,
);
} else if (el.type === 'line') {
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
parts.push(
`<line x1="${mm(el.x1)}" y1="${mm(el.y1)}" ` +
`x2="${mm(el.x2)}" y2="${mm(el.y2)}" ` +
`stroke="black" stroke-width="${strokeWidth}"/>`,
);
}
}
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`,
` width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`,
` <rect width="${W}" height="${H}" fill="white"/>`,
` ${parts.join('\n ')}`,
`</svg>`,
].join('\n');
const resvg = new Resvg(svg, {
font: {
loadSystemFonts: true,
fontDirs: ['/usr/share/fonts', '/usr/local/share/fonts'],
defaultFontFamily: 'Liberation Sans',
sansSerifFamily: 'Liberation Sans',
},
});
return Buffer.from(resvg.render().asPng());
}
Bekannte Eigenheiten
- Baseline-Problem:
librsvg(verwendet von@resvg/resvg-js) ignoriertdominant-baseline. Daher muss zur Y-Koordinate die Schriftgröße addiert werden, damit die Oberkante des Textes der eingegebenen Y-Position entspricht (s.yBaseline-Berechnung). - Schriften: Auf Systemen ohne
Liberation Sans(z.B. Windows) fällt die Bibliothek auf Arial zurück. Für plattformübergreifende Konsistenz können Schriften auch per Buffer eingebettet werden. - QR-Code:
errorCorrectionLevel: 'M'ist ein guter Kompromiss zwischen Fehlertoleranz und Dichte.
5. Job Queue & SSE-Mechanismus
Originaldateien:
paperless-backend/src/label-print-agent/label-print-agent.service.tspaperless-backend/src/label-print-agent/label-print-agent.controller.ts
Job erstellen
// Originaldatei: label-print-agent.service.ts
function applyVars(template: string, vars: Record<string, string>): string {
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
const colonIdx = key.indexOf(':');
if (colonIdx !== -1) {
const varName = key.slice(0, colonIdx);
const width = parseInt(key.slice(colonIdx + 1), 10);
if (!isNaN(width) && varName in vars) {
return vars[varName].padStart(width, '0');
}
}
return vars[key] ?? '';
});
}
async createJob(templateId: number, fieldValues: Record<string, string>): Promise<LabelPrintJob> {
const template = await this.templateRepo.findOne({ where: { Id: templateId } });
if (!template) throw new NotFoundException('Template nicht gefunden');
const vars: Record<string, string> = { ...fieldValues };
// Datum-Felder aufsplitten: {datum} → dd.MM.yyyy, {datum.year}, {datum.month}, {datum.day}
for (const field of template.LabelInputFields ?? []) {
if (field.type === 'date' && fieldValues[field.name]) {
const parts = fieldValues[field.name].split('-'); // YYYY-MM-DD
if (parts.length === 3) {
vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`;
vars[`${field.name}.year`] = parts[0];
vars[`${field.name}.month`] = parts[1];
vars[`${field.name}.day`] = parts[2];
}
}
}
// Externe Nummern-Reservierung (optional)
if (template.LabelGetUrl) {
const url = applyVars(template.LabelGetUrl, vars);
const res = await fetch(url);
vars['number'] = (await res.text()).trim();
}
const job = this.jobRepo.create({
Status: 'pending',
LabelImageData: null, // Lazy rendering: erst beim Claim rendern
LabelWidthMm: template.LabelWidthMm ?? 57,
LabelHeightMm: template.LabelHeightMm ?? 32,
BarcodeTemplateId: template.Id,
LabelVariables: vars,
LockedAt: null,
LockedByAgent: null,
});
const saved = await this.jobRepo.save(job);
this.jobCreated$.next(); // SSE-Notification an alle verbundenen Agents
return saved;
}
Job beanspruchen (Optimistic Locking)
// Lock-Ablaufzeit: 5 Minuten in der Vergangenheit
function lockExpiry(): Date {
const d = new Date();
d.setMinutes(d.getMinutes() - 5);
return d;
}
async claimNextJob(agentId: string): Promise<LabelPrintJob | null> {
// Finde den ältesten pending Job ohne Lock oder mit abgelaufenem Lock
const candidate = await this.jobRepo.findOne({
where: [
{ Status: 'pending', LockedAt: IsNull() },
{ Status: 'pending', LockedAt: LessThan(lockExpiry()) },
],
order: { CreatedAt: 'ASC' }, // FIFO
});
if (!candidate) return null;
// Lock setzen (optimistisch, kein DB-Row-Lock)
candidate.LockedAt = new Date();
candidate.LockedByAgent = agentId;
await this.jobRepo.save(candidate);
// Lazy Render: PNG erst jetzt erzeugen
if (!candidate.LabelImageData) {
const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId } });
if (template?.LabelLayout?.length) {
candidate.LabelImageData = await this.renderer.render(
template.LabelLayout,
candidate.LabelWidthMm,
candidate.LabelHeightMm,
candidate.LabelVariables ?? {},
);
await this.jobRepo.save(candidate);
}
}
return candidate;
}
SSE-Stream (NestJS)
// Originaldatei: label-print-agent.controller.ts
private readonly jobCreated$ = new Subject<void>(); // im Service
// Controller:
@Sse('events')
sseEvents(@Res({ passthrough: true }) res: Response): Observable<MessageEvent> {
res.setHeader('X-Accel-Buffering', 'no'); // nginx: kein Buffering
return this.service.newJob$.pipe(
map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)),
);
}
SSE-Client (Agent-Seite, Plain JavaScript)
const evtSource = new EventSource('http://backend/api/label-print-agent/events', {
headers: { Authorization: `Bearer ${token}` },
});
evtSource.onmessage = async (event) => {
const data = JSON.parse(event.data);
if (data.type === 'label-job-available') {
await pollAndPrint();
}
};
// Fallback: alle 30 Sekunden pollen
setInterval(pollAndPrint, 30_000);
async function pollAndPrint() {
const res = await fetch(`http://backend/api/label-print-agent/jobs/next?agentId=MEIN-PC`);
if (res.status === 204) return; // kein Job
const job = await res.json();
const imageBytes = Buffer.from(job.labelImageBase64, 'base64');
try {
await printToPrinter(imageBytes, job.labelWidthMm, job.labelHeightMm);
await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/printed`, {
method: 'POST',
body: JSON.stringify({ agentId: 'MEIN-PC', printerName: 'DYMO 450' }),
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/error`, {
method: 'POST',
body: JSON.stringify({ agentId: 'MEIN-PC', errorMessage: String(err) }),
headers: { 'Content-Type': 'application/json' },
});
}
}
6. Variablen-System
Template-Syntax
| Syntax | Bedeutung | Beispiel |
|---|---|---|
{feldname} |
Wert des Feldes | {name} → Müller GmbH |
{feldname:6} |
Zero-padded auf N Stellen | {nr:6} → 000042 |
{datum} (date-Feld) |
Formatiert als dd.MM.yyyy |
{datum} → 15.05.2026 |
{datum.year} |
Nur das Jahr | {datum.year} → 2026 |
{datum.month} |
Nur den Monat | {datum.month} → 05 |
{datum.day} |
Nur den Tag | {datum.day} → 15 |
{number} |
Reservierte Nummer (via GetUrl) | {number} → 1234 |
URL-Templates
Die Felder LabelGetUrl, LabelPrintedUrl, LabelReleaseUrl unterstützen ebenfalls Variablen-Substitution:
LabelGetUrl: https://api.example.com/numbers/next?batch={batch}
LabelPrintedUrl: https://api.example.com/numbers/{number}/confirm
LabelReleaseUrl: https://api.example.com/numbers/{number}/release
Sicherheitscheck: Nur http:// und https:// werden akzeptiert.
7. Vollständiger Job-Lebenszyklus
Status: pending → (Lock gesetzt) → printed / error
┌─ createJob() ──────────────────────────────────────────────────┐
│ Template laden → Vars aufbauen → GetUrl aufrufen (optional) │
│ Job speichern: Status=pending, LockedAt=null, imageData=null │
│ newJob$.next() → SSE-Push │
└─────────────────────────────────────────────────────────────────┘
┌─ claimNextJob() ───────────────────────────────────────────────┐
│ Kandidat suchen: pending AND (LockedAt IS NULL OR < -5min) │
│ Lock setzen: LockedAt=now, LockedByAgent=agentId │
│ Lazy Render: Layout + Vars → SVG → PNG → LabelImageData │
│ Rückgabe: Job + PNG (base64) │
└─────────────────────────────────────────────────────────────────┘
┌─ markPrinted() ────────────────────────────────────────────────┐
│ Status = printed, PrintedAt = now, PrintedByAgent, Printer │
│ POST LabelPrintedUrl (optional, Variablen aufgelöst) │
└─────────────────────────────────────────────────────────────────┘
┌─ markError() ──────────────────────────────────────────────────┐
│ Status = error, ErrorMessage │
│ POST LabelReleaseUrl (optional) │
└─────────────────────────────────────────────────────────────────┘
Lock-TTL: 5 Minuten. Stürzt ein Agent ab, kann ein anderer Agent (oder derselbe nach Neustart) den Job nach 5 Minuten erneut beanspruchen.
8. NestJS-Modul-Setup
// label-print-agent.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([LabelPrintJob, LabelTemplate]),
],
controllers: [LabelPrintAgentController],
providers: [LabelPrintAgentService, LabelRendererService],
})
export class LabelPrintAgentModule {}
9. Empfehlungen für das neue Projekt
Architektur übernehmen
Das Muster DB als Queue + SSE als Push + Polling als Fallback ist einfach und robust. Es benötigt keine externe Message-Queue (Redis, RabbitMQ etc.) und funktioniert gut für geringe bis mittlere Job-Volumina.
Locking-Strategie
| Szenario | Empfehlung |
|---|---|
| Ein Agent | Optimistic Lock (5-Min-TTL) reicht aus |
| Mehrere Agents, wenige Jobs | Optimistic Lock reicht aus |
| Viele Agents, hoher Durchsatz | DB-Row-Lock (SELECT ... FOR UPDATE) oder Redis-Lock |
Rendering-Alternativen
| Umgebung | Alternative |
|---|---|
| Node.js | @resvg/resvg-js + qrcode (wie hier) |
| Node.js (Docker) | Puppeteer (HTML → PNG/PDF, braucht Chrome) |
| Python | reportlab oder cairosvg |
| Kein Node.js | Server-seitiges Rendering via separatem Microservice |
SSE-Alternativen
| Alternative | Wann |
|---|---|
| SSE (wie hier) | Einfachste Lösung, funktioniert durch nginx |
| WebSocket | Wenn bidirektionale Kommunikation nötig ist |
| Polling-only | Wenn der Agent kein persistentes HTTP unterstützt |
Template-Verwaltung im neuen Projekt
Im Paperless Manager sind Label-Templates Teil der BarcodeTemplate-Entity (historisch gewachsen). Für ein neues Projekt empfiehlt sich eine eigenständige LabelTemplate-Entity ohne nicht-Label-relevante Felder — das vereinfacht CRUD und API erheblich.
10. Quell-Dateien (Paperless Manager)
| Datei | Inhalt |
|---|---|
paperless-backend/src/label-print-agent/label-print-agent.service.ts |
Job-Lifecycle, Locking, Callbacks |
paperless-backend/src/label-print-agent/label-print-agent.controller.ts |
REST-Endpunkte, SSE |
paperless-backend/src/label-print-agent/label-renderer.service.ts |
SVG→PNG Rendering |
paperless-backend/src/database/entities/label-print-job.entity.ts |
Job-Entity |
paperless-backend/src/database/entities/barcode-template.entity.ts |
Template-Entity (inkl. LabelElement, LabelInputField Typen) |
paperless-frontend/src/api/labelPrintAgent.ts |
Frontend-API-Client |
11. Frontend-UI: Template-Editor (SettingsPage)
Originaldatei: paperless-frontend/src/pages/SettingsPage.tsx
Der Template-Editor ist ein Modal-Formular (720px breit) mit Ant Design. Er enthält zwei eigenständige React-Komponenten.
Komponenten-Übersicht
| Komponente | Zweck |
|---|---|
BarcodeTemplatesTab |
Tabelle mit allen Templates + CRUD-Logik |
LabelElementRow |
Eine Zeile im Layout-Editor (typ-abhängige Felder) |
BarcodeTemplatesTab
Verwaltet den gesamten Template-Lebenszyklus. State:
const [data, setData] = useState<BarcodeTemplate[]>([]);
const [editing, setEditing] = useState<BarcodeTemplate | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [testPrinting, setTestPrinting] = useState(false);
const [form] = Form.useForm();
Formular-Initialwerte (beim Neu-Anlegen):
form.setFieldsValue({
SplitBefore: false,
DateinameTemplate: '',
LabelEnabled: false,
LabelWidthMm: 57, // Standard: DYMO 57mm
LabelHeightMm: 32,
LabelInputFields: [],
LabelLayout: [],
});
Testetikett-Funktion (handleTestLabel):
const handleTestLabel = async () => {
const values = await form.validateFields();
await barcodeTemplatesApi.update(editing.Id, values); // erst speichern
// Auto-Testwerte für alle Felder generieren
const testFieldValues: Record<string, string> = {};
const today = new Date().toISOString().slice(0, 10);
for (const f of values.LabelInputFields ?? []) {
if (f.type === 'date') testFieldValues[f.name] = today;
else if (f.type === 'number') testFieldValues[f.name] = '1';
else testFieldValues[f.name] = 'Test';
}
const url = await labelPrintAgentApi.previewLabel(editing.Id, testFieldValues);
setPreviewUrl(url); // öffnet zweites Modal mit PNG
};
Modal-Footer-Buttons:
[Testetikett erstellen] [Abbrechen] [Speichern]
↑
Nur sichtbar wenn bestehende Vorlage (editing && !isNew)
Formular-Aufbau (innerhalb des Modals)
┌─ Name ────────────────────────────────────────────────────────┐
│ Input: "z. B. Lager-Etikett" (required) │
└───────────────────────────────────────────────────────────────┘
┌─ Regex ───────────────────────────────────────────────────────┐
│ Input mit Regex-Validator │
│ Extra: "JavaScript-Syntax, z. B. ^\d{4}-\d{6}-\d{8}$" │
└───────────────────────────────────────────────────────────────┘
┌─ Regex testen ────────────────────────────────────────────────┐
│ Input (live) → Tag: [Treffer] / [Kein Treffer] / [Ungültig] │
└───────────────────────────────────────────────────────────────┘
┌─ Checkbox: "Vor diesem Barcode ein neues Dokument starten" ───┐
└───────────────────────────────────────────────────────────────┘
┌─ Belegname ───────────────────────────────────────────────────┐
│ Input: Platzhalter {barcode}, {datum}, {barcode.gruppe} │
└───────────────────────────────────────────────────────────────┘
────────────── Etikett ──────────────
┌─ Switch: "Etikett-Druck aktivieren" ─────────────────────────┐
└───────────────────────────────────────────────────────────────┘
[Nur sichtbar wenn Switch aktiv]
┌─ Breite (mm) │ Höhe (mm) ─────────────────────────────────┐
│ InputNumber │ InputNumber (Standard: 57 / 32) │
└──────────────────────────────────────────────────────────────┘
┌─ Eingabefelder ─────────────────────────────────────────────┐
│ Form.List: je Zeile: │
│ [Feldname Input] [Bezeichnung Input] [Typ-Select] [−] │
│ Typen: text | number | date │
│ [+ Feld hinzufügen] │
└──────────────────────────────────────────────────────────────┘
┌─ GET-URL ───────────────────────────────────────────────────┐
│ Extra zeigt verfügbare Platzhalter live aus Eingabefeldern │
└──────────────────────────────────────────────────────────────┘
┌─ PRINTED-URL ───────────────────────────────────────────────┐
│ Extra: Platzhalter + {number} │
└──────────────────────────────────────────────────────────────┘
┌─ RELEASE-URL ───────────────────────────────────────────────┐
│ Extra: Platzhalter + {number} │
└──────────────────────────────────────────────────────────────┘
┌─ Layout-Elemente ───────────────────────────────────────────┐
│ Extra: verfügbare Platzhalter │
│ Form.List → je Element: LabelElementRow-Komponente │
│ [+ Text] [+ QR-Code] [+ Linie] │
└──────────────────────────────────────────────────────────────┘
Platzhalter-Chips werden live berechnet:
// Aus den aktuellen LabelInputFields dynamisch erzeugt
const chips: string[] = [];
for (const f of inputFields) {
chips.push(`{${f.name}}`);
if (f.type === 'date') {
chips.push(`{${f.name}.year}`, `{${f.name}.month}`, `{${f.name}.day}`);
}
}
const chipsWithNumber = [...chips, '{number}'];
// → wird als `extra`-Text bei URL-Feldern und Layout-Elementen angezeigt
LabelElementRow (je Layout-Element eine Card)
Typ wird per Form.useWatch reaktiv ausgelesen → zeigt typ-spezifische Felder:
const type = Form.useWatch(['LabelLayout', listName, 'type'], form);
Typ: Text
[X mm] [Y mm] [Schrift (mm)] [Max. Breite (mm)] [Fett ☐] [Ausrichtung ▼]
[Inhalt: Input mit Platzhalter-Hinweis "{nummer} oder {datum}"]
Default beim Hinzufügen: { type: 'text', x: 0, y: 0, fontSize: 3, content: '', bold: false }
Typ: QR-Code
[X mm] [Y mm] [Größe (mm)] [Inhalt: Input mit "{number}"]
Default: { type: 'qr', x: 0, y: 0, sizeMm: 20, content: '' }
Typ: Linie
[X1 mm] [Y1 mm] [X2 mm] [Y2 mm] [Stärke (mm, step: 0.1)]
Default: { type: 'line', x1: 0, y1: 0, x2: 50, y2: 0 }
Jede Card hat rechts oben einen Löschen-Button (DeleteOutlined).
Vorschau-Modal
Nach „Testetikett erstellen" öffnet sich ein zweites Modal (520px) mit dem gerenderten PNG:
<Modal title="Etikett-Vorschau" width={520}>
<div style={{ background: '#e0e0e0', padding: 24, display: 'flex', justifyContent: 'center' }}>
<img src={previewUrl} style={{ maxWidth: '100%', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }} />
</div>
</Modal>
Das previewUrl ist ein URL.createObjectURL(blob) — muss mit URL.revokeObjectURL() beim Schließen freigegeben werden.
12. Frontend-UI: Druck-Dialog (InboxPage)
Originaldatei: paperless-frontend/src/pages/InboxPage.tsx
Einstiegspunkt
Button in der Toolbar der InboxPage:
<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
Etikett drucken
</Button>
openPrintDialog
Lädt beim Öffnen parallel Templates und User-Settings (für Standard-Template):
const openPrintDialog = async () => {
const [all, settings] = await Promise.all([
barcodeTemplatesApi.list(),
userSettingsApi.get(),
]);
const enabled = all.filter((t) => t.LabelEnabled); // nur aktive Templates
setLabelTemplates(enabled);
// Standard-Template aus User-Settings vorauswählen
const defaultId = settings.defaultLabelTemplateId;
const defaultTemplate = defaultId != null
? (enabled.find((t) => t.Id === defaultId) ?? null)
: null;
setSelectedTemplate(defaultTemplate);
setFieldValues(buildInitialFieldValues(defaultTemplate));
setLabelCount(1);
setPrintDialogOpen(true);
};
Datum-Vorausfüllung
function buildInitialFieldValues(template: BarcodeTemplate | null): Record<string, string> {
const today = dayjs().format('YYYY-MM-DD');
const values: Record<string, string> = {};
for (const field of template?.LabelInputFields ?? []) {
if (field.type === 'date') values[field.name] = today;
// text- und number-Felder bleiben leer
}
return values;
}
Druck-Modal-Aufbau
┌─ Eingangsdokumentart ─────────────────────────────────────────┐
│ Select → nur Templates mit LabelEnabled=true │
└───────────────────────────────────────────────────────────────┘
┌─ Anzahl Etiketten ────────────────────────────────────────────┐
│ InputNumber (min: 1, max: 100, default: 1) │
└───────────────────────────────────────────────────────────────┘
┌─ [dynamisch: LabelInputFields des gewählten Templates] ────────┐
│ date → DatePicker (Format: DD.MM.YYYY, Wert: YYYY-MM-DD) │
│ number → InputNumber │
│ text → Input │
└───────────────────────────────────────────────────────────────┘
Footer: [Abbrechen] [Drucken] ← disabled solange kein Template
Batch-Druck
const handlePrint = async () => {
setPrinting(true);
for (let i = 0; i < labelCount; i++) {
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
// Erzeugt N separate Jobs sequenziell (selbe fieldValues)
}
message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`);
};
13. Frontend API-Client
Originaldatei: paperless-frontend/src/api/labelPrintAgent.ts
export const labelPrintAgentApi = {
// Job erstellen (von InboxPage)
createJob: (templateId: number, fieldValues: Record<string, string>) =>
api.post('/api/label-print-agent/jobs', { templateId, fieldValues }),
// Vorschau-PNG als Object-URL (von SettingsPage)
previewLabel: async (templateId: number, fieldValues: Record<string, string>): Promise<string> => {
const res = await api.post('/api/label-print-agent/preview',
{ templateId, fieldValues },
{ responseType: 'blob' },
);
return URL.createObjectURL(res.data); // muss nach Verwendung revoked werden
},
};