From d67295234e130d4861b5932864d66fa86ab4cc5a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 29 Aug 2026 00:00:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(client):=20=E6=8A=BD=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=20LabelTemplate=20=E5=8D=95=E6=BA=90,=E4=BB=B7?= =?UTF-8?q?=E7=AD=BE=E6=B8=B2=E6=9F=93=E6=94=B9=E8=AF=BB=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?(P0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 价签版式原 100% 硬编码在四条渲染路径。引入 label_template.dart 声明式模型(纸张/打印/抬头/文字栈/QR/条码),builtinDefault() 逐值冻结 重构前常量;画布渲染器 _renderLabelBitmap 与热敏 _printFlatLabelThermal 改为纯读模型,坐标统一到画布权威。facade/web stub 加 template 可选参 (null→默认,现有调用点行为不变)。 新增 label_render_golden 零回归基准:默认模板渲染与重构前逐像素零 diff。 附评审通过的设计文档 label-template-editor.html(5 项决策)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5 --- client/lib/core/utils/label_template.dart | 507 ++++++++++++++++++ client/lib/core/utils/print_util.dart | 9 +- client/lib/core/utils/print_util_stub.dart | 276 +++++----- client/lib/core/utils/print_util_web.dart | 43 +- .../golden/goldens/label_render_default.png | Bin 0 -> 39264 bytes .../test/golden/label_render_golden_test.dart | 83 +++ docs/design/label-template-editor.html | 205 +++++++ docs/index.html | 1 + 8 files changed, 965 insertions(+), 159 deletions(-) create mode 100644 client/lib/core/utils/label_template.dart create mode 100644 client/test/golden/goldens/label_render_default.png create mode 100644 client/test/golden/label_render_golden_test.dart create mode 100644 docs/design/label-template-editor.html diff --git a/client/lib/core/utils/label_template.dart b/client/lib/core/utils/label_template.dart new file mode 100644 index 0000000..78cda83 --- /dev/null +++ b/client/lib/core/utils/label_template.dart @@ -0,0 +1,507 @@ +import 'label_data.dart'; + +/// 价签版式声明式模型(单一真源)。 +/// +/// 设计铁律(2026-08-28 已评审通过): +/// - **画布 = 唯一几何权威**。渲染器(`_renderLabelBitmap`)是「纯绝对坐标画笔」, +/// header/textStack/qr/barcode 的坐标、字号、开关全从本模型读; +/// 预览与热敏文字光栅共用同一套画布,所见即所得。 +/// - 热敏保留原生 BARCODE + 独立位图 QR,但**坐标同样由本模型驱动**(决策②), +/// 与画布同源(不再各自硬编码)。 +/// - 编辑器负责「算坐标」(移动 QR 时联动重算左列宽度等),渲染器只读绝对值。 +/// - `builtinDefault()` 把重构前 `print_util_stub.dart` 的硬编码常量逐值冻结为绝对值, +/// 保证默认模板渲染与重构前**逐像素一致**(由 `label_render_golden_test.dart` 守闸)。 +/// +/// MVP 边界:字体只做字号 + 粗体(不换字族、不增包,决策④);颜色不暴露(热敏单色,决策⑤)。 +/// 存储:整棵树 toJson 存 `shop.custom_fields.label_templates`(List)+ `label_template_active`(id)。 + +/// 文本水平对齐(MVP:左/中/右;渲染器映射到 `ui.TextAlign`)。 +enum LabelAlign { left, center, right } + +/// 可绑定到标签字段的商品/门店数据源。 +enum LabelBinding { + shopName, + name, + code, + series, + spec, + productionDate, + batchNo, + remark, + shopAddress, + shopPhone, +} + +LabelAlign _alignFrom(String? s) => + LabelAlign.values.firstWhere((e) => e.name == s, + orElse: () => LabelAlign.center); + +LabelBinding? _bindingFrom(String? s) { + for (final e in LabelBinding.values) { + if (e.name == s) return e; + } + return null; +} + +/// 把绑定解析为其在 [d] 上的字符串值(空字段返回空串;日期截断到 10 字符)。 +String resolveBinding(LabelBinding b, LabelData d) { + switch (b) { + case LabelBinding.shopName: + return d.shopName; + case LabelBinding.name: + return d.name; + case LabelBinding.code: + return d.code; + case LabelBinding.series: + return d.series ?? ''; + case LabelBinding.spec: + return d.spec ?? ''; + case LabelBinding.productionDate: + final p = d.productionDate ?? ''; + return p.length > 10 ? p.substring(0, 10) : p; + case LabelBinding.batchNo: + return d.batchNo ?? ''; + case LabelBinding.remark: + return d.remark ?? ''; + case LabelBinding.shopAddress: + return d.shopAddress; + case LabelBinding.shopPhone: + return d.shopPhone; + } +} + +/// 纸张与打印分辨率。逻辑像素 = mm / 25.4 * dpi(四舍五入)。 +class LabelPaper { + double widthMm; + double heightMm; + int dpi; + + LabelPaper({this.widthMm = 40, this.heightMm = 20, this.dpi = 203}); + + int get logicalW => (widthMm / 25.4 * dpi).round(); // 40mm@203 → 320 + int get logicalH => (heightMm / 25.4 * dpi).round(); // 20mm@203 → 160 + + Map toJson() => + {'widthMm': widthMm, 'heightMm': heightMm, 'dpi': dpi}; + + factory LabelPaper.fromJson(Map j) => LabelPaper( + widthMm: (j['widthMm'] as num?)?.toDouble() ?? 40, + heightMm: (j['heightMm'] as num?)?.toDouble() ?? 20, + dpi: (j['dpi'] as num?)?.toInt() ?? 203, + ); + + LabelPaper copy() => LabelPaper(widthMm: widthMm, heightMm: heightMm, dpi: dpi); +} + +/// 热敏 TSPL 打印参数(高级配置区暴露)。 +class LabelPrint { + int density; + int speed; + int direction; + int copies; + double gapMm; + + LabelPrint({ + this.density = 10, + this.speed = 2, + this.direction = 1, + this.copies = 1, + this.gapMm = 2, + }); + + Map toJson() => { + 'density': density, + 'speed': speed, + 'direction': direction, + 'copies': copies, + 'gapMm': gapMm, + }; + + factory LabelPrint.fromJson(Map j) => LabelPrint( + density: (j['density'] as num?)?.toInt() ?? 10, + speed: (j['speed'] as num?)?.toInt() ?? 2, + direction: (j['direction'] as num?)?.toInt() ?? 1, + copies: (j['copies'] as num?)?.toInt() ?? 1, + gapMm: (j['gapMm'] as num?)?.toDouble() ?? 2, + ); + + LabelPrint copy() => LabelPrint( + density: density, + speed: speed, + direction: direction, + copies: copies, + gapMm: gapMm); +} + +/// 抬头栏:深蓝底反白店名(binding 固定为 shopName)。 +class LabelHeader { + bool show; + double height; // 逻辑像素 + double textX; + double textY; + double fontSize; + bool bold; + LabelAlign align; + + LabelHeader({ + this.show = true, + this.height = 24, + this.textX = 10, + this.textY = 4, + this.fontSize = 14, + this.bold = true, + this.align = LabelAlign.left, + }); + + Map toJson() => { + 'show': show, + 'height': height, + 'textX': textX, + 'textY': textY, + 'fontSize': fontSize, + 'bold': bold, + 'align': align.name, + }; + + factory LabelHeader.fromJson(Map j) => LabelHeader( + show: j['show'] as bool? ?? true, + height: (j['height'] as num?)?.toDouble() ?? 24, + textX: (j['textX'] as num?)?.toDouble() ?? 10, + textY: (j['textY'] as num?)?.toDouble() ?? 4, + fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14, + bold: j['bold'] as bool? ?? true, + align: _alignFrom(j['align'] as String?), + ); + + LabelHeader copy() => LabelHeader( + show: show, + height: height, + textX: textX, + textY: textY, + fontSize: fontSize, + bold: bold, + align: align); +} + +/// 大号自适应品名字段。fontSize == null → 在 [fontMin, fontMax] 内自适应放大。 +class LabelNameField { + bool show; + double? fontSize; // null = 自适应 + double fontMin; + double fontMax; + bool bold; + LabelAlign align; + + LabelNameField({ + this.show = true, + this.fontSize, + this.fontMin = 12, + this.fontMax = 18, + this.bold = true, + this.align = LabelAlign.center, + }); + + Map toJson() => { + 'show': show, + 'fontSize': fontSize, + 'fontMin': fontMin, + 'fontMax': fontMax, + 'bold': bold, + 'align': align.name, + }; + + factory LabelNameField.fromJson(Map j) => LabelNameField( + show: j['show'] as bool? ?? true, + fontSize: (j['fontSize'] as num?)?.toDouble(), + fontMin: (j['fontMin'] as num?)?.toDouble() ?? 12, + fontMax: (j['fontMax'] as num?)?.toDouble() ?? 18, + bold: j['bold'] as bool? ?? true, + align: _alignFrom(j['align'] as String?), + ); + + LabelNameField copy() => LabelNameField( + show: show, + fontSize: fontSize, + fontMin: fontMin, + fontMax: fontMax, + bold: bold, + align: align); +} + +/// 副字段行:一行内若干绑定按 4 空格拼接(任一为空则跳过;整行为空则该行消失)。 +/// 每行是「一个字段」的最小单元时即可实现「逐字段独立字号」。 +class LabelSubLine { + List bindings; + double fontSize; + bool bold; + LabelAlign align; + + LabelSubLine({ + required this.bindings, + this.fontSize = 14, + this.bold = false, + this.align = LabelAlign.center, + }); + + Map toJson() => { + 'bindings': bindings.map((b) => b.name).toList(), + 'fontSize': fontSize, + 'bold': bold, + 'align': align.name, + }; + + factory LabelSubLine.fromJson(Map j) => LabelSubLine( + bindings: ((j['bindings'] as List?) ?? const []) + .map((s) => _bindingFrom(s as String?)) + .whereType() + .toList(), + fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14, + bold: j['bold'] as bool? ?? false, + align: _alignFrom(j['align'] as String?), + ); + + LabelSubLine copy() => LabelSubLine( + bindings: List.of(bindings), + fontSize: fontSize, + bold: bold, + align: align); +} + +/// 左列文字区域:品名 + 若干副字段行,整体在 [top, bottom] 竖直区间内均分。 +class LabelTextStack { + double x; + double width; + double top; + double bottom; + double lineHeight; + LabelNameField name; + List subLines; + + LabelTextStack({ + this.x = 8, + this.width = 164, + this.top = 30, + this.bottom = 110, + this.lineHeight = 1.3, + required this.name, + required this.subLines, + }); + + Map toJson() => { + 'x': x, + 'width': width, + 'top': top, + 'bottom': bottom, + 'lineHeight': lineHeight, + 'name': name.toJson(), + 'subLines': subLines.map((l) => l.toJson()).toList(), + }; + + factory LabelTextStack.fromJson(Map j) => LabelTextStack( + x: (j['x'] as num?)?.toDouble() ?? 8, + width: (j['width'] as num?)?.toDouble() ?? 164, + top: (j['top'] as num?)?.toDouble() ?? 30, + bottom: (j['bottom'] as num?)?.toDouble() ?? 110, + lineHeight: (j['lineHeight'] as num?)?.toDouble() ?? 1.3, + name: j['name'] is Map + ? LabelNameField.fromJson( + (j['name'] as Map).cast()) + : LabelNameField(), + subLines: ((j['subLines'] as List?) ?? const []) + .map((m) => + LabelSubLine.fromJson((m as Map).cast())) + .toList(), + ); + + LabelTextStack copy() => LabelTextStack( + x: x, + width: width, + top: top, + bottom: bottom, + lineHeight: lineHeight, + name: name.copy(), + subLines: subLines.map((l) => l.copy()).toList(), + ); + + /// 组装非空副字段行(复刻旧 `_labelSubFields` 语义)。 + List composeSubs(LabelData d) { + final out = []; + for (final line in subLines) { + final parts = line.bindings + .map((b) => resolveBinding(b, d)) + .where((s) => s.isNotEmpty) + .toList(); + final s = parts.join(' '); + if (s.isNotEmpty) out.add(s); + } + return out; + } +} + +/// 二维码区域(正方形,绝对坐标)。 +class LabelQr { + bool show; + double x; + double y; + double size; + + LabelQr({this.show = true, this.x = 178, this.y = 25, this.size = 134}); + + Map toJson() => + {'show': show, 'x': x, 'y': y, 'size': size}; + + factory LabelQr.fromJson(Map j) => LabelQr( + show: j['show'] as bool? ?? true, + x: (j['x'] as num?)?.toDouble() ?? 178, + y: (j['y'] as num?)?.toDouble() ?? 25, + size: (j['size'] as num?)?.toDouble() ?? 134, + ); + + LabelQr copy() => LabelQr(show: show, x: x, y: y, size: size); +} + +/// 一维条码区域(Code128,内容 = 商品编号,绝对坐标)。 +class LabelBarcode { + bool show; + double x; + double y; + double width; + double height; + + LabelBarcode( + {this.show = true, + this.x = 8, + this.y = 114, + this.width = 164, + this.height = 40}); + + Map toJson() => + {'show': show, 'x': x, 'y': y, 'width': width, 'height': height}; + + factory LabelBarcode.fromJson(Map j) => LabelBarcode( + show: j['show'] as bool? ?? true, + x: (j['x'] as num?)?.toDouble() ?? 8, + y: (j['y'] as num?)?.toDouble() ?? 114, + width: (j['width'] as num?)?.toDouble() ?? 164, + height: (j['height'] as num?)?.toDouble() ?? 40, + ); + + LabelBarcode copy() => + LabelBarcode(show: show, x: x, y: y, width: width, height: height); +} + +/// 一套完整的价签版式。 +class LabelTemplate { + final String id; + String name; + LabelPaper paper; + LabelPrint print; + LabelHeader header; + LabelTextStack textStack; + LabelQr qr; + LabelBarcode barcode; + + LabelTemplate({ + required this.id, + required this.name, + required this.paper, + required this.print, + required this.header, + required this.textStack, + required this.qr, + required this.barcode, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'paper': paper.toJson(), + 'print': print.toJson(), + 'header': header.toJson(), + 'textStack': textStack.toJson(), + 'qr': qr.toJson(), + 'barcode': barcode.toJson(), + }; + + factory LabelTemplate.fromJson(Map j) { + final def = builtinDefault(); + Map? sub(String k) => + j[k] is Map ? (j[k] as Map).cast() : null; + return LabelTemplate( + id: (j['id'] as String?)?.isNotEmpty == true + ? j['id'] as String + : def.id, + name: (j['name'] as String?)?.isNotEmpty == true + ? j['name'] as String + : def.name, + paper: sub('paper') != null ? LabelPaper.fromJson(sub('paper')!) : def.paper, + print: sub('print') != null ? LabelPrint.fromJson(sub('print')!) : def.print, + header: + sub('header') != null ? LabelHeader.fromJson(sub('header')!) : def.header, + textStack: sub('textStack') != null + ? LabelTextStack.fromJson(sub('textStack')!) + : def.textStack, + qr: sub('qr') != null ? LabelQr.fromJson(sub('qr')!) : def.qr, + barcode: sub('barcode') != null + ? LabelBarcode.fromJson(sub('barcode')!) + : def.barcode, + ); + } + + LabelTemplate copyWith({String? id, String? name}) => LabelTemplate( + id: id ?? this.id, + name: name ?? this.name, + paper: paper.copy(), + print: print.copy(), + header: header.copy(), + textStack: textStack.copy(), + qr: qr.copy(), + barcode: barcode.copy(), + ); + + /// 内置默认模板:逐值冻结重构前 `print_util_stub.dart` 的画布常量。 + /// 改任一常量前先确认 `label_render_golden_test.dart` 仍零 diff。 + static LabelTemplate builtinDefault() => LabelTemplate( + id: 'builtin-default', + name: '默认版式', + paper: LabelPaper(widthMm: 40, heightMm: 20, dpi: 203), + print: LabelPrint(density: 10, speed: 2, direction: 1, copies: 1, gapMm: 2), + header: LabelHeader( + show: true, + height: 24, + textX: 10, + textY: 4, + fontSize: 14, + bold: true, + align: LabelAlign.left), + textStack: LabelTextStack( + x: 8, + width: 164, + top: 30, + bottom: 110, + lineHeight: 1.3, + name: LabelNameField( + show: true, + fontSize: null, + fontMin: 12, + fontMax: 18, + bold: true, + align: LabelAlign.center), + subLines: [ + LabelSubLine( + bindings: [LabelBinding.code, LabelBinding.series], + fontSize: 14, + bold: false, + align: LabelAlign.center), + LabelSubLine( + bindings: [LabelBinding.spec, LabelBinding.productionDate], + fontSize: 14, + bold: false, + align: LabelAlign.center), + ], + ), + qr: LabelQr(show: true, x: 178, y: 25, size: 134), + barcode: + LabelBarcode(show: true, x: 8, y: 114, width: 164, height: 40), + ); +} diff --git a/client/lib/core/utils/print_util.dart b/client/lib/core/utils/print_util.dart index 83ffc4a..659f8aa 100644 --- a/client/lib/core/utils/print_util.dart +++ b/client/lib/core/utils/print_util.dart @@ -5,6 +5,8 @@ import '../../models/stock_out.dart'; import '../errors/error_reporter.dart'; import 'label_data.dart'; export 'label_data.dart'; +import 'label_template.dart'; +export 'label_template.dart'; import 'order_print_meta.dart'; import '../theme/context_tokens.dart'; import '../../widgets/ds/ds_toast.dart'; @@ -25,6 +27,7 @@ Future printProductLabel({ String shopAddress = '', String shopPhone = '', String? printerName, + LabelTemplate? template, }) => printProductLabelImpl( qrBytes: qrBytes, @@ -39,11 +42,13 @@ Future printProductLabel({ shopAddress: shopAddress, shopPhone: shopPhone, printerName: printerName, + template: template, ); /// 渲染标签预览图(PNG 字节);Web 平台返回 null。 -Future renderLabelPreview(LabelData label) => - renderLabelPreviewImpl(label); +Future renderLabelPreview(LabelData label, + {LabelTemplate? template}) => + renderLabelPreviewImpl(label, template: template); /// 枚举当前系统所有可用打印机名;Web 平台返回空列表。 Future> listLabelPrinters() => listLabelPrintersImpl(); diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index dda5d97..6fe1703 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -25,6 +25,7 @@ import 'package:printing/printing.dart'; import '../../models/stock_in.dart'; import '../../models/stock_out.dart'; import 'label_data.dart'; +import 'label_template.dart'; import 'order_print_meta.dart'; Future _loadFont() async { @@ -158,7 +159,7 @@ bool _winRawPrint(String printerName, Uint8List data) { void _drawText( ui.Canvas canvas, String s, double x, double y, double size, double maxW, {bool bold = false, - bool center = false, + ui.TextAlign textAlign = ui.TextAlign.left, ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑) { if (s.isEmpty) return; @@ -166,7 +167,7 @@ void _drawText( fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊) fontSize: size, fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal, - textAlign: center ? ui.TextAlign.center : ui.TextAlign.left, + textAlign: textAlign, maxLines: 1, ellipsis: '', )) @@ -176,6 +177,22 @@ void _drawText( canvas.drawParagraph(p, ui.Offset(x, y)); } +/// 毫米数格式化为 TSPL 数值(整数不带小数点:40.0 → "40")。 +String _tsplMm(double v) => + v == v.roundToDouble() ? v.round().toString() : v.toString(); + +/// 版式对齐 → 画布 TextAlign。 +ui.TextAlign _uiAlign(LabelAlign a) { + switch (a) { + case LabelAlign.left: + return ui.TextAlign.left; + case LabelAlign.center: + return ui.TextAlign.center; + case LabelAlign.right: + return ui.TextAlign.right; + } +} + /// 在 canvas 上把 [data] 画成 Code128 一维条码(黑条,铺满 x,y,w,h 区域,不带人读字符)。 /// 扫码枪扫 1D 条码;条码内容 = 商品编号,与出库扫码一致。 void _drawBarcode( @@ -215,46 +232,19 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) { return size; } -/// 组装标签左列名下两行副字段(居中排布): -/// 行1 = 编号 + 度数(型号) 行2 = 规格(版本) + 生产日期 -/// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。 -/// 各字段为空则跳过(该行可只剩一个字段或整行消失);日期截断到 10 字符。四路渲染共用。 -List _labelSubFields( - String code, String? series, String? spec, String? productionDate) { - final date = (productionDate ?? '').isNotEmpty - ? (productionDate!.length > 10 - ? productionDate.substring(0, 10) - : productionDate) - : ''; - final line1 = [ - if (code.isNotEmpty) code, - if ((series ?? '').isNotEmpty) series!, - ].join(' '); - final line2 = [ - if ((spec ?? '').isNotEmpty) spec!, - if (date.isNotEmpty) date, - ].join(' '); - return [ - if (line1.isNotEmpty) line1, - if (line2.isNotEmpty) line2, - ]; -} - /// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。 /// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。 /// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。 Future _renderLabelBitmap({ - required String shop, - required String name, - required String code, - required List subs, // 左列逐行副字段:型号规格 / 日期(已过滤空) - required Uint8List qrBytes, + required LabelTemplate tpl, // 版式单源:坐标/字号/开关全从此读 + required LabelData data, // 待打印字段值 + qrBytes bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码 bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码 }) async { - const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸) + final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸) + final int h = tpl.paper.logicalH; // 20mm@203 → 160 const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐) - const sw = w * scale, sh = h * scale; + final int sw = w * scale, sh = h * scale; final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble())); @@ -271,63 +261,66 @@ Future _renderLabelBitmap({ canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()), ui.Paint()..color = cWhite); - // ── 深蓝店名头 ────────────────────────────────────────────── - const headerH = 24.0; - canvas.drawRect( - ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy); - _drawText(canvas, shop, 10, 4, 14, w - 20, bold: true, color: cCream); + // ── 深蓝店名头(binding 固定 shopName)────────────────────────── + final header = tpl.header; + if (header.show) { + canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), header.height), + ui.Paint()..color = cNavy); + _drawText(canvas, data.shopName, header.textX, header.textY, + header.fontSize, w - 2 * header.textX, + bold: header.bold, textAlign: _uiAlign(header.align), color: cCream); + } - // ── 主体两列布局(无页脚:主体直抵纸底,2026-08-27 删掉底部米色尺寸条)──────── - const bodyTop = headerH; // 24 - const double bodyBot = 160.0; // 直抵纸底 h(原到页脚顶 145),二维码/条码顺势放大 - const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间 - const double colGap = 6.0; // 左列与二维码列间距(原 10,收小) - - // 右列:二维码略放大,占主体近满高(正方形),2026-08-28 用户口径「二维码调大一点」 - const double qrPad = 1.0; - const double qrSize = (bodyBot - bodyTop) - 2 * qrPad; // 134 - const double qrTop = bodyTop + qrPad; // 25 - const double qrLeft = w - padH - qrSize; // 178 - if (drawQr) { + // ── 右列二维码(正方形,绝对坐标)───────────────────────────── + final qr = tpl.qr; + if (drawQr && qr.show) { try { - final codec = await ui.instantiateImageCodec(qrBytes); + final codec = await ui.instantiateImageCodec(data.qrBytes!); final qrImg = (await codec.getNextFrame()).image; canvas.drawImageRect( qrImg, ui.Rect.fromLTWH( 0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()), - const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize), + ui.Rect.fromLTWH(qr.x, qr.y, qr.size, qr.size), ui.Paint()); } catch (e) { debugPrint('[label] QR 解码失败: $e'); } } - // 左列几何 - const double leftX = padH; // 8 - const double leftRight = qrLeft - colGap; // 172 - const double leftW = leftRight - leftX; // 164 + // ── 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 ── + final barcode = tpl.barcode; + if (drawBarcode && barcode.show) { + _drawBarcode( + canvas, data.code, barcode.x, barcode.y, barcode.width, barcode.height); + } - // 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 - const double barH = 40.0; - const double barBottom = bodyBot - padV; // 154 - const double barTop = barBottom - barH; // 114 - if (drawBarcode) _drawBarcode(canvas, code, leftX, barTop, leftW, barH); + // ── 左列文字块:品名 + 若干副字段行,整体在 [top, bottom] 区间竖直均分 ── + final ts = tpl.textStack; + final double leftX = ts.x; + final double leftW = ts.width; + final double infoTop = ts.top; + final double infoBot = ts.bottom; + final double infoH = infoBot - infoTop; - // 左列文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体水平居中、 - // 竖直均分。2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。 - const double infoTop = bodyTop + padV; // 30 - const double infoBot = barTop - 4; // 110 - const double infoH = infoBot - infoTop; // 80 + // 逐行装配(text, size, bold, align):品名自适应/固定;副字段行按各自样式。 + final rows = <(String, double, bool, LabelAlign)>[]; + if (ts.name.show && data.name.isNotEmpty) { + final double nameSize = ts.name.fontSize ?? + _fitFont(data.name, leftW, 1000, ts.name.bold, ts.name.fontMax) + .clamp(ts.name.fontMin, ts.name.fontMax); + rows.add((data.name, nameSize, ts.name.bold, ts.name.align)); + } + for (final line in ts.subLines) { + final parts = line.bindings + .map((b) => resolveBinding(b, data)) + .where((s) => s.isNotEmpty) + .toList(); + final s = parts.join(' '); + if (s.isNotEmpty) rows.add((s, line.fontSize, line.bold, line.align)); + } - const double kSmallSize = 14.0; // 编号+度数 / 规格+日期(2026-08-28 调大一号 12→14) - final double nameSize = - _fitFont(name, leftW, 1000, true, 18.0).clamp(12.0, 18.0); - final rows = <(String, double, bool)>[ - if (name.isNotEmpty) (name, nameSize, true), // 品名粗体 - for (final s in subs) (s, kSmallSize, false), // 编号+度数 / 规格+日期 - ]; - const double kLineH = 1.3; // 行高倍数 + final double kLineH = ts.lineHeight; // 行高倍数 final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH); final int n = rows.length; final double rawGap = n > 0 ? (infoH - totalTextH) / (n + 1) : 2.0; @@ -335,7 +328,7 @@ Future _renderLabelBitmap({ double yy = infoTop + gap; for (final r in rows) { _drawText(canvas, r.$1, leftX, yy, r.$2, leftW, - bold: r.$3, color: r.$3 ? cInk : cCode, center: true); + bold: r.$3, color: r.$3 ? cInk : cCode, textAlign: _uiAlign(r.$4)); yy += r.$2 * kLineH + gap; } @@ -349,11 +342,8 @@ Future _renderLabelBitmap({ /// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。 /// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。 Future _printFlatLabelThermal({ - required String shop, - required String name, - required String code, - required List subs, // 度数 / 规格 / 日期(已过滤空) - required Uint8List qrBytes, + required LabelTemplate tpl, // 版式单源:QR/条码坐标 + TSPL 参数全从此读 + required LabelData data, String? printerName, }) async { if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false; @@ -361,19 +351,18 @@ Future _printFlatLabelThermal({ debugPrint('[label] thermal printer = $printer'); if (printer == null) return false; - const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸) - const wBytes = (w + 7) >> 3; // 40 bytes/row + final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸) + final int h = tpl.paper.logicalH; // 20mm@203 → 160 + final int wBytes = (w + 7) >> 3; // 40 bytes/row + final code = data.code; // ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)───────── // 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化: // 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。 // 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。 final img = await _renderLabelBitmap( - shop: shop, - name: name, - code: code, - subs: subs, - qrBytes: qrBytes, + tpl: tpl, + data: data, drawBarcode: false, drawQr: false, ); @@ -383,10 +372,13 @@ Future _printFlatLabelThermal({ const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致 const int subTotal = ss * ss; // 16 子像素/墨点 + // TSPL 头部参数由 template 驱动(纸张/间隙/方向/密度/速度)。 + final p = tpl.print; final buf = BytesBuilder(); buf.add(ascii.encode( - 'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n' - 'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n', + 'SIZE ${_tsplMm(tpl.paper.widthMm)} mm,${_tsplMm(tpl.paper.heightMm)} mm\r\n' + 'GAP ${_tsplMm(p.gapMm)} mm,0 mm\r\nDIRECTION ${p.direction}\r\n' + 'REFERENCE 0,0\r\nDENSITY ${p.density}\r\nSPEED ${p.speed}\r\nCLS\r\n', )); // 整幅文字位图(mode 0 覆写整版):深蓝抬头→黑底反白,其余白底黑字, @@ -413,37 +405,44 @@ Future _printFlatLabelThermal({ } buf.add(ascii.encode('\r\n')); - // ── 右列二维码(原生 BITMAP,避免光栅降采样糊码):占主体整高、右对齐 ── - const qrW = 130, qrH = 130; // 2026-08-28 放大,对齐画布 QR(134) - const qrX = 320 - 8 - qrW; // 182 - const bodyTop = 24, bodyBot = 160; // 无页脚:主体直抵纸底 - const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 27 - const qrWBytes = (qrW + 7) >> 3; // 13 - final qrCodec = await ui.instantiateImageCodec(qrBytes, - targetWidth: qrW, targetHeight: qrH); - final qrImg = (await qrCodec.getNextFrame()).image; - final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba); - final qrRgba = qrBd!.buffer.asUint8List(); - buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,')); - for (int yy = 0; yy < qrH; yy++) { - final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); - for (int xx = 0; xx < qrW; xx++) { - final i = (yy * qrW + xx) * 4; - final a = qrRgba[i + 3]; - final lum = a < 128 - ? 255.0 - : 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2]; - if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); + // ── 右列二维码(原生 BITMAP,避免光栅降采样糊码),坐标由 template 驱动(与画布同源)── + if (tpl.qr.show && data.qrBytes != null) { + final int qrW = tpl.qr.size.round(); + final int qrH = qrW; + final int qrX = tpl.qr.x.round(); + final int qrY = tpl.qr.y.round(); + final int qrWBytes = (qrW + 7) >> 3; + final qrCodec = await ui.instantiateImageCodec(data.qrBytes!, + targetWidth: qrW, targetHeight: qrH); + final qrImg = (await qrCodec.getNextFrame()).image; + final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba); + final qrRgba = qrBd!.buffer.asUint8List(); + buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,')); + for (int yy = 0; yy < qrH; yy++) { + final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); + for (int xx = 0; xx < qrW; xx++) { + final i = (yy * qrW + xx) * 4; + final a = qrRgba[i + 3]; + final lum = a < 128 + ? 255.0 + : 0.299 * qrRgba[i] + + 0.587 * qrRgba[i + 1] + + 0.114 * qrRgba[i + 2]; + if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); + } + buf.add(row); } - buf.add(row); + buf.add(ascii.encode('\r\n')); } - buf.add(ascii.encode('\r\n')); - // ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),narrow 自适应铺满居中 ── - const leftX = 8, leftRight = qrX - 6, leftW = leftRight - leftX; // 8..188, 180 - const kBarH = 34; - const kBarY = bodyBot - 8 - kBarH; // 118 - if (code.isNotEmpty) { + // ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),坐标由 template 驱动 ── + // narrow 模块宽自适应铺满区域并水平居中。 + if (tpl.barcode.show && code.isNotEmpty) { + final int leftX = tpl.barcode.x.round(); + final int leftW = tpl.barcode.width.round(); + final int leftRight = leftX + leftW; + final int kBarH = tpl.barcode.height.round(); + final int kBarY = tpl.barcode.y.round(); final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验) final narrow = (leftW / estModules).floor().clamp(2, 4); final barW = estModules * narrow; @@ -452,7 +451,7 @@ Future _printFlatLabelThermal({ 'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n')); } - buf.add(ascii.encode('PRINT 1,1\r\n')); + buf.add(ascii.encode('PRINT 1,${p.copies}\r\n')); final tspl = buf.toBytes(); debugPrint('[label] raster BITMAP+QR+BARCODE tspl ${tspl.length}B -> $printer'); @@ -473,8 +472,23 @@ Future printProductLabelImpl({ String shopAddress = '', String shopPhone = '', String? printerName, + LabelTemplate? template, // 版式单源;null → 内置默认(行为同重构前) }) async { try { + final tpl = template ?? LabelTemplate.builtinDefault(); + final data = LabelData( + qrBytes: qrBytes, + name: name, + code: code, + spec: spec, + series: series, + batchNo: batchNo, + productionDate: productionDate, + remark: remark, + shopName: shopName, + shopAddress: shopAddress, + shopPhone: shopPhone, + ); final font = await _loadFont(); final doc = pw.Document(); final qrImage = pw.MemoryImage(qrBytes); @@ -484,17 +498,12 @@ Future printProductLabelImpl({ const labelH = 20.0 * PdfPageFormat.mm; const headerH = labelH * 0.20; - // 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致) - final subs = _labelSubFields(code, series, spec, productionDate); + // 副字段行由 template 装配(口径与预览、TSPL 一致) + final subs = tpl.textStack.composeSubs(data); // 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印) if (await _printFlatLabelThermal( - shop: shopName, - name: name, - code: code, - subs: subs, - qrBytes: qrBytes, - printerName: printerName)) { + tpl: tpl, data: data, printerName: printerName)) { return; } @@ -617,15 +626,12 @@ Future printProductLabelImpl({ } /// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。 -/// Web 平台或 qrBytes 为空时抛异常。 -Future renderLabelPreviewImpl(LabelData label) async { +/// [template] 为空时用内置默认版式(行为同重构前)。Web 平台或 qrBytes 为空时抛异常。 +Future renderLabelPreviewImpl(LabelData label, + {LabelTemplate? template}) async { final img = await _renderLabelBitmap( - shop: label.shopName, - name: label.name, - code: label.code, - subs: _labelSubFields( - label.code, label.series, label.spec, label.productionDate), - qrBytes: label.qrBytes!, + tpl: template ?? LabelTemplate.builtinDefault(), + data: label, ); final bd = await img.toByteData(format: ui.ImageByteFormat.png); return bd!.buffer.asUint8List(); diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index 2b005f2..2518e12 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -7,6 +7,7 @@ import '../../models/stock_in.dart'; import '../../models/stock_out.dart'; import '../errors/error_reporter.dart'; import 'label_data.dart'; +import 'label_template.dart'; import 'order_print_meta.dart'; void _openPrintWindow(String html) { @@ -31,30 +32,26 @@ Future printProductLabelImpl({ String shopAddress = '', String shopPhone = '', String? printerName, + LabelTemplate? template, }) async { final base64Img = base64Encode(qrBytes); - final specVal = (spec ?? '').isNotEmpty ? spec! : ''; - final seriesVal = (series ?? '').isNotEmpty ? series! : ''; - final dateVal = (productionDate ?? '').isNotEmpty - ? (productionDate!.length > 10 - ? productionDate.substring(0, 10) - : productionDate) - : ''; - // 左列名下两行副字段(居中):行1=编号+度数、行2=规格+日期(口径与预览、PDF、TSPL 一致) - // 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。 - final line1 = [ - if (code.isNotEmpty) code, - if (seriesVal.isNotEmpty) seriesVal, - ].join(' '); - final line2 = [ - if (specVal.isNotEmpty) specVal, - if (dateVal.isNotEmpty) dateVal, - ].join(' '); - final subs = [ - if (line1.isNotEmpty) line1, - if (line2.isNotEmpty) line2, - ]; + // 副字段行由 template 装配(口径与预览、PDF、TSPL 一致;null → 内置默认版式)。 + final tpl = template ?? LabelTemplate.builtinDefault(); + final data = LabelData( + qrBytes: qrBytes, + name: name, + code: code, + spec: spec, + series: series, + batchNo: batchNo, + productionDate: productionDate, + remark: remark, + shopName: shopName, + shopAddress: shopAddress, + shopPhone: shopPhone, + ); + final subs = tpl.textStack.composeSubs(data); final subLines = subs.map((s) => '
$s
').join(); // 商品名过长时缩小字号 final nameLen = name.runes.length; @@ -299,7 +296,9 @@ Future printStockInOrderImpl( // ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)───────────────────────── -Future renderLabelPreviewImpl(LabelData label) async => null; +Future renderLabelPreviewImpl(LabelData label, + {LabelTemplate? template}) async => + null; Future> listLabelPrintersImpl() async => []; diff --git a/client/test/golden/goldens/label_render_default.png b/client/test/golden/goldens/label_render_default.png new file mode 100644 index 0000000000000000000000000000000000000000..9a276ee030c5c4fcac9b750ddb26649a455174c0 GIT binary patch literal 39264 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 _fixedQrPng() async { + const cells = 10; + const cell = 24.0; // 240×240 + final rec = ui.PictureRecorder(); + final canvas = ui.Canvas(rec); + final paint = ui.Paint(); + for (var y = 0; y < cells; y++) { + for (var x = 0; x < cells; x++) { + paint.color = (x + y) % 2 == 0 + ? const ui.Color(0xFF000000) + : const ui.Color(0xFFFFFFFF); + canvas.drawRect(ui.Rect.fromLTWH(x * cell, y * cell, cell, cell), paint); + } + } + final img = await rec + .endRecording() + .toImage((cells * cell).round(), (cells * cell).round()); + final bd = await img.toByteData(format: ui.ImageByteFormat.png); + return bd!.buffer.asUint8List(); +} + +void main() { + testWidgets('价签默认版式 · 零回归基准', (tester) async { + await ensureGoldenFonts(); + + Uint8List? png; + bool comparePassed = false; + + await tester.runAsync(() async { + final label = LabelData( + qrBytes: await _fixedQrPng(), + name: '贵州茅台酒', + code: 'P1001', + series: '飞天53度', + spec: '500ml', + productionDate: '2024-05-18', + shopName: '鼎晟酒行', + ); + + png = await renderLabelPreview(label); + if (png == null) return; + + final uri = Uri.parse(_goldenUri); + if (autoUpdateGoldenFiles) { + await goldenFileComparator.update(uri, png!); + comparePassed = true; + } else { + comparePassed = await goldenFileComparator.compare(png!, uri); + } + }); + + expect(png, isNotNull, reason: 'renderLabelPreview 在测试(VM/桌面)下应返回 PNG'); + expect(comparePassed, isTrue, + reason: '默认模板渲染与基准不一致:零回归被破坏(先在重构前 main 上 --update-goldens 生成基准)'); + }); +} diff --git a/docs/design/label-template-editor.html b/docs/design/label-template-editor.html new file mode 100644 index 0000000..c09e412 --- /dev/null +++ b/docs/design/label-template-editor.html @@ -0,0 +1,205 @@ + + + + + +标签模板编辑器 设计方案 — 酒库管理系统 + + + +

标签模板编辑器 · 设计方案(代码实证版 v2)

+
给「设置 → 打印模板 · 商品价签」的「编辑」按钮做可视化模板编辑器 · 2026-08-28 · 本版所有结论已逐行核对源码(不采信二手调研)· 方案评审(未实现)
+ +
+ 目标不变:店家自定义价签版式(选字段 / 调字号字体位置 / 实时预览 / 多样式命名 / 打印参数进高级)。 + 本版把上一版两处关键判断按源码订正了:① 二维码走的是位图不是原生指令; + ② PDF / Web 两条路径用的是 flexbox 流式布局、根本没有绝对坐标——这直接改变了「统一渲染」的做法。 + 结论:真正要数据驱动的只有画布(预览) + 热敏(实打)这一条主线,PDF/Web 用「贴位图」收口即可, + 比上一版设想的工作量更小、风险更低。 +
+ +

1. 现状(逐行核对源码)

+
+

标签有四条输出路径,各自布局机制如下(已核对,非调研二手):

+ + + + + + +
路径位置何时走布局机制条码 / 二维码
画布print_util_stub.dart:246 _renderLabelBitmap预览弹窗 + 热敏文字光栅(同一函数,drawQr/drawBarcode=false 供热敏)绝对坐标(逻辑 320×160,4× 超采样)条码 _drawBarcode 画黑条(:315);QR drawImageRect(:295)
热敏 TSPL同文件 _printFlatLabelThermal:351检测到 DL-888B 等热敏机(真实主力)文字=画布光栅 BITMAP(:394);绝对坐标条码=原生 BARCODE(:451);QR=位图 BITMAP(:427)
PDF 回退同文件 printProductLabelImpl:463只在检测不到热敏机时(:491-499)flexbox 流式pw.Column/Row/Expanded),无绝对坐标条码 pw.BarcodeWidget 矢量(:578);QR pw.Image(:593)
Web HTMLprint_util_web.dart:21Web 构建(window.print()CSS flexbox 流式(:82-124),无绝对坐标条码 toSvg 矢量(:64);QR base64 img(:143)
+ +
订正 ①(我上一版说错的):热敏路径里二维码是 BITMAP $qrX,$qrY,...(第 427 行)——位图,不是原生 QRCODE 指令。它「相对清晰」的原因是独立渲染:把 QR PNG 按目标尺寸 130×130 单独解码 + 1:1 逐像素 128 阈值二值化(:422-436),不经过文字那条 4×4 超采样降采样链路。只有条码是真·原生 TSPL BARCODE 指令(:451)。
+ +
订正 ②(影响架构):PDF 和 Web 不是绝对坐标布局,是 flexbox 流式(PDF 用 pw.Column/Row/Expanded,Web 用 CSS flex)。这意味着——如果编辑器让用户「把字段拖到某个 x/y」,这套坐标天然只对得上画布/热敏,对不上 PDF/Web 的 flex 流。要让 PDF/Web 认坐标,就得把它们的 flex 布局整个换掉。这正是下一节改方案的原因。
+ +

1.1 画布布局其实是「自适应」的,不是死坐标

+

关键细节(_renderLabelBitmap:319-340):品名字号 _fitFont 按宽度自适应 clamp(12,18);文字块(品名 + 两行副字段)在剩余竖直空间里自动均分留白居中;副字段 _labelSubFields:222 把「编号+系列」拼一行、「规格+日期」拼一行,空字段自动跳过、整行塌缩。也就是说当前版式不是一组固定像素位置,而是「几个区域 + 自动排版」。这点直接决定版式模型该怎么建(见 §3)。

+ +

1.2 实际渲染 / 未渲染的字段

+

参与渲染:shopName / name / code / series / spec / productionDate / qr / barcode已在 LabelData 但从不渲染batchNo / remark / shopAddress / shopPhone(label_data.dart:15-20) → 编辑器可直接把它们做成可选字段,数据层零改动

+ +

1.3 入口与存储(已核对,利好)

+
    +
  • 「编辑」是空实现device_management_screen.dart:991_snack('模板编辑即将上线');「预览」_previewLabelTemplate:1002 造示例 LabelDataLabelPreviewDialog打印模板卡仅桌面渲染 → 编辑器只做桌面。
  • +
  • 打印入口是扁平参数print_util.dart:15 printProductLabel(...):45 renderLabelPreview(label) 都不带模板概念 → 要加一个 template 参数贯穿下去(改动集中在 facade + 两个 Impl)。
  • +
  • 后端 custom_fields 已支持增量 mergeshop.go:68-79 读现有 custom_fields → 合并新键 → 保留旧键、店隔离,还有钉死契约的测试 shop_test.go:31。前端 _savePeripherals:1031 就是这么存外设的。→ 模板存 custom_fields 零后端改动、且不会冲掉外设等已有键。
  • +
+
+ +

2. 渲染架构(按源码修订:画布为唯一权威 + PDF/Web 贴位图)

+
+

上一版我提「抽一层绘制指令 IR 给四路径各写 adapter」。读完代码后这是过度设计——因为 PDF/Web 是流式布局、且只是回退/次要面。更省的做法:

+
让「画布」成为唯一的几何权威。 模板 = 画布上的绝对布局;画布同时驱动预览热敏文字光栅(热敏文字本就是画布栅格化的)。PDF/Web 直接嵌入画布渲染出的位图,不再各自摆版。四套手工对齐的布局 → 收敛成一套画布渲染器 + 两个「贴位图」消费者 + 热敏既有的条码/QR 特判
+ + + + + + +
改法条码/QR 清晰度
预览画布 → PNG(现状即是)屏幕显示,无所谓
热敏(主力)文字=画布光栅(现状);条码=原生 BARCODE、QR=独立 BITMAP都保留);唯一改动:它们的坐标从写死的 const 改成由模板算出不降级:条码原生矢量、QR 独立 1:1 阈值,扫码可靠性照旧
PDF 回退整个 pw.* 组件树换成单张 pw.Image(画布位图) 满页铺回退面向办公激打/喷墨(≥300dpi),画布 1280px≈812dpi 栅格化,条码可扫
WebHTML flex 换成 <img> 画布 PNG(40×20mm)同上,Web 打印走高 dpi,无虞
+

注:条码/QR 光栅化「扫不出」的风险只发生在热敏 203dpi——所以热敏那条坚决保留原生条码 + 独立 QR,不动。PDF/Web 是高 dpi 面,贴位图完全够用。

+
附带红利:因为 PDF/Web 只是回退/次要面,它们的模板落地可以延后——先做画布+热敏(覆盖真实打印 + 预览),PDF/Web 后面「贴位图」一句话的事。比上一版少写两个 adapter。
+
+ +

3. 版式模型:区域 + 自动栈(贴合现有自适应布局)

+
+

因为 §1.1 里现状是「区域 + 自动排版」而非死坐标,模型不宜做成纯自由画布(否则丢掉品名自适应、竖直自动居中这些现有优点,且短名不再放大、隐藏字段不再回填空间 → 反而变差)。建议区域(region) + 元素(element) 混合模型:

+
LabelTemplate { + id, name // 可命名 / 重命名 + paper: { width_mm:40, height_mm:20, dpi:203 } + print: { density:10, speed:2, gap_mm:2, direction:1, copies:1 } // 高级设置 + regions: { + header: { visible, height_mm, bg, fields:[ text... ] } // 深蓝抬头 + textStack:{ x,y,w,h, align, fields:[ text... ] } // 自动竖直均分(保留现有算法) + qr: { visible, x, y, size } // 右列二维码 + barcode: { visible, x, y, w, h, symbology:code128, showText } // 左下条码 + } +} +text field { binding, staticText?, fontSize(或 auto), bold, align, color } +binding ∈ shopName|shopAddress|shopPhone|name|code|series|spec| + batchNo|productionDate|remark|static
+
    +
  • 字段开关:勾选控制 field 是否进 textStack / 是否显示 QR/条码。
  • +
  • 调样式:字号(或 auto 自适应)、粗体、对齐、颜色。
  • +
  • 调位置:QR / 条码 / 抬头 = 可移动可缩放的区域(覆盖「调位置」诉求最实用的部分);textStack 内字段 = 调顺序 + 可选手动微调,默认仍走现有自动均分。
  • +
  • 零回归:内置「默认模板」= 把现有 const(headerH=24 / qrSize=134 / barTop=114 …)原样编码;渲染器保留自动栈算法。用字体探针 / golden 位图对照守逐像素一致。
  • +
+
+ +

4. 存储:shop.custom_fields(已被源码证实可行、零后端改动)

+
+

模板存 shop.custom_fields.label_templates(数组)+ label_template_active(默认 id)。依据:

+
    +
  • 后端 shop.go:68-79 对 custom_fields 做增量 merge 并有测试钉死 → 写 label_templates 不碰 peripherals 等旧键,店隔离。无需新表/迁移/接口。
  • +
  • 前端照抄 _savePeripherals:1031:读 shopInfoProvider → 改 customFields map → shopRepositoryProvider.updateInfo({...})
  • +
  • 店级 → 跨设备/员工同步;打印机选择继续留本地 label_printer(label_preview_dialog.dart:11) 不动。
  • +
+
+ +

5. 编辑器交互(桌面专属)

+
+

价签卡「编辑」→ 大对话框。左预览右属性,改动 debounce ~150ms 调 renderLabelPreview(sample, template) 重渲染(预览路径现成)。

+
┌─ 标签模板编辑器 ──────────────────────────────────────────┐ +│ 模板:[商品价签(默认)▾] [新建][复制][重命名][删除][设默认] │ +├──────────────────────────┬──────────────────────────────┤ +│ 实时预览(真实渲染位图) │ 字段 │ +│ ┌──────────────────┐ │ ☑店名 ☑品名 ☑编号 ☑系列 │ +│ │ 岩美酒行旗舰店 │ │ ☑规格 ☑生产日期 ☐批次 ☐备注 │ +│ │ 茅台飞天五十三度 │▨ │ ☑二维码 ☑底部条码 │ +│ │ P1001 飞天 │▨ │ ──────────────────────────── │ +│ │ 53度 2024-06-01 │ │ 选中「品名」: │ +│ │ ▐▌▐ ▌▐▌▌▐ ▌▐▌▐ │ │ 字号[自适应▾] □粗体 │ +│ └──────────────────┘ │ 对齐[居中▾] 颜色[■] │ +│ [示例▾][用真实商品▾] │ 选中「二维码」: X[178]Y[25] │ +│ │ 尺寸[134] │ +│ │ ──────────────────────────── │ +│ │ ▸ 高级(纸张/DPI/密度/速度) │ +├──────────────────────────┴──────────────────────────────┤ +│ [取消] [保存] │ +└──────────────────────────────────────────────────────────┘
+
+ +

6. 两个需你定调的点(含源码约束)

+
+

决策 A:字段自由度 —— 纯自由画布会丢现有自适应

+

你要「可调位置」,但源码现状是自适应排版(品名按宽度放大、文字块自动竖直居中、空字段塌缩)。三档:

+
    +
  • ① 槽位式:只开关+调样式不能移位。稳,但不满足「调位置」。
  • +
  • ② 区域+自动栈 推荐:QR/条码/抬头做成可移动缩放的区域(满足「调位置」最实用部分);文字块内保留自动均分。既能调位置又不丢现有优点。
  • +
  • ③ 纯自由画布:每字段任意 x/y。最强,但丢掉品名自适应/自动居中,40×20mm 上还易重叠溢出。
  • +
+

推荐 。若你更想要 ③ 的「每个字段都能拖」,我按纯坐标做,但要接受品名不再自动放大、隐藏字段不再自动回填空间。

+
+ +
+

决策 B:字体与颜色 —— 热敏是单色,有物理约束

+
    +
  • 颜色:热敏机是单色介质,文字二值化后全是黑(抬头深蓝也退成黑)。所以「字段颜色」只对预览/PDF/Web 有视觉意义,对热敏实打无效。要不要暴露颜色,取决于你在不在乎屏幕/PDF 上的彩色观感。
  • +
  • 字体_drawText:166 写死 fontFamily:'NotoSansSC',且热敏要求打包字体。支持「换字体」= 再打包字体(如宋体,每款中文约 4–8MB 增包体)+ 参数化 fontFamily。MVP 建议只做字号/粗体(零增包),多字体二期按需。
  • +
+
+ +

7. 落地路线(design-first,主线先行)

+
+ + + + + + +
内容验收
P0 建模定义 LabelTemplate(区域+自动栈)+ 画布渲染器读模型;内置默认模板=现 const;facade/Impl 加 template 参数字体探针 / golden:默认模板逐像素零回归
P1 原型design/prototype/ 新增编辑器桌面原型5180 serve URL 评审,过了再写代码
P2 编辑器编辑器 UI + custom_fields 存储 + 预览联动 + 多模板/重命名;热敏条码/QR 坐标改模板驱动桌面建/存/切模板,预览实时刷新,热敏按模板出签
P3 收口(可延后)PDF/Web 改「贴画布位图」→ 自定义模板四面一致同模板热敏/PDF/Web 观感一致
+

按铁律:P1 原型过审前不写实现代码;前端改动原型与代码同提交。本方案即 P1 前评审。

+
+ +

8. 待你拍板

+
+ + + + + + + +
#决策推荐
1模板存哪shop.custom_fields(源码证实零后端改动、店级同步)
2渲染统一画布为唯一权威 + PDF/Web 贴位图;热敏保留原生条码/独立 QR,仅坐标受模板驱动
3字段自由度② 区域+自动栈(可调位置又不丢自适应)
4字体MVP 只字号/粗体,多字体二期(增包体)
5颜色热敏无效 → 建议 MVP 不暴露颜色(或仅作预览观感),你定
+

给个方向(认同/改哪条),我就做 P0 建模并出 P1 编辑器原型。

+
+ +
核对源码:print_util_stub.dart(:158-617) / print_util_web.dart / label_data.dart / print_util.dart / label_preview_dialog.dart / device_management_screen.dart(:930-1035) / 后端 shop.go(:51-79)+shop_test.go · 本方案未改任何代码。
+ + diff --git a/docs/index.html b/docs/index.html index c00769c..15f9724 100644 --- a/docs/index.html +++ b/docs/index.html @@ -46,6 +46,7 @@
  • 扫码出库(扫码枪扫二维码建出库单)设计HTML — 扫码枪=HID键盘,解析已印二维码URL取public_id→新增鉴权接口映射本店product_id→装配明细行;含数量口径待决
  • 扫码出库 · 实现计划(出库表单扫码+1)HTML — 落到 stock_out_form_screen:扫码框承接URL→取?code=→本地整仓索引命中→明细+1;方案A纯前端(≤1000SKU)/B加后端精确查;design-first先改原型
  • 库存筛选规格MD
  • +
  • 标签模板编辑器(价签版式自定义)设计HTML — 现状四路径硬编码→抽声明式 LabelTemplate 单源;编辑器桌面专属左预览右属性;存 shop.custom_fields;含 5 项待拍板决策(2026-08-28 方案评审)
  • 📚 知识库 · 调研